Cross-Field Validation#

This is the 20th article in the Django Ninja series.
In the previous article, we finished discussing custom validation for a single field. In this article, we will discuss validation across multiple fields.
Cross-field validation is also a very common requirement in API development. For example, when registering an account, you need to ensure that the "password" and "confirm password" fields contain the same content; when selecting a date range, the start date cannot be later than the end date.
These validation scenarios cannot be implemented through single-field validation because they require checking the logical relationship between multiple fields to ensure overall data consistency and correctness.
This article will introduce how to implement cross-field validation using Pydantic—taking "confirm password" as an example to demonstrate the practical application of this feature.
All code changes in this article can be referred to in this PR.
GitHub Example Project#
Cross-Field Validation and Separation of Concerns#
Actually, neither single-field nor cross-field custom validation necessarily has to be done via Pydantic.
Theoretically, data validation can be performed directly within view functions—for example, retrieving the input field values and manually validating their legitimacy. The same goes for cross-field validation.
However, this is a convenient but "crude" approach—suitable only when the validation logic is extremely simple.
Performing data validation via Pydantic, on the other hand, brings a distinct benefit: Separation of Concerns.
Separation of Concerns#
Separation of Concerns is a design principle that advocates partitioning the responsibilities of different features in a program into independent modules or layers.
Each module focuses on a specific responsibility, thereby avoiding coupling unrelated concerns. This division makes the program easier to test, maintain, and extend.
According to the Separation of Concerns principle, data validation logic should be centralized in the Schema, rather than being handled inside view functions.
This way, the view can focus on handling core business logic, leaving data validation to a dedicated component.
Using Pydantic's validation mechanism, we can achieve Separation of Concerns by separating data validation from business logic. This not only improves the structure of the code but also makes the development workflow clearer and more stable.
New Requirement: Confirm Password#
We will implement a feature that is very simple, yet sufficient to fully demonstrate the value of cross-field validation: confirm password.
First, let's review the request Schema for the "Create User" API at the end of the previous article:
class CreateUserRequest(Schema):
username: str = Field(examples=['Alice'])
email: str = Field(examples=['alice@example.com'])
password: str = Field(min_length=8, examples=['password123'])
bio: str | None = Field(
default=None, examples=['Hello, I am Alice.'])
...
This Schema design is obviously insufficient.
Because when users register, the password usually needs to be entered twice, and the second input serves as a "confirmation"—important things should be said twice!
Therefore, we need to add a confirm_password field and perform cross-field validation with password to ensure both have identical content.
Although the validation logic involved is very simple, it serves as the perfect stage for cross-field validation.
Implementing Cross-Field Validation: Using model_validator#
Pydantic v2 introduced the @model_validator decorator to handle cross-field validation, which is an improvement and replacement for @root_validator in Pydantic v1.
The "model" here refers to Pydantic's BaseModel—which is our Schema—not Django's Models.
We will enhance the "Create User" API by adding the "confirm password" feature using @model_validator.
Let's look directly at the modified code:
class CreateUserRequest(Schema):
...
password: str = Field(min_length=8, examples=['password123'])
confirm_password: str = Field(
min_length=8, examples=['password123'])
...
@model_validator(mode='after')
def check_passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise ValueError('Password and password confirmation must match')
return self
Key Points#
- Added a
confirm_passwordfield. - Used the
@model_validator(mode='after')decorator to define the cross-field validation method. - There are three modes in total:
before,after, andwrap. The details are quite numerous. Due to space constraints, this article cannot expand on them (perhaps to be supplemented in an extra post). - You only need to know that most of the time
aftermode is used, in which the validation method is an "instance method," and theselfparameter represents the Schema instance itself (initialized from the input data). - The validation method
check_passwords_matchcompares thepasswordandconfirm_passwordfields. If they do not match, aValueErroris raised. - As mentioned earlier, although the logic is very simple, it indeed implements validation between two fields.
- Cross-field validation is executed after all single-field validations are completed.
Practical Application of Separation of Concerns#
You will find that during the implementation of the "confirm password" feature, the view function remained completely unchanged!—This is the embodiment of the Separation of Concerns principle.
Compared to directly implementing validation logic in the view function (which would require modifying both the view and the Schema), this implementation is undoubtedly cleaner and more decoupled.
HTTP Responses on Validation Failure#
Finally, let's look at what kind of HTTP response we get when data validation fails.
The first two were mentioned in the previous article, and are listed again here for comparison and review.
Violating Password Length Constraints#
The response is as follows:
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
}
]
}
This is the "system-level" response given by Django Ninja when capturing Pydantic validation errors, with a status code of 422.
Violating the "Must Contain Numbers" Rule#
If the input password does not contain numbers, the response is as follows:
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload",
"password"
],
"msg": "Value error, password must contain at least one number",
"ctx": {
"error": "password must contain at least one number"
}
}
]
}
Looks almost the same? Yes, because this is also Django Ninja's automatic response format—except that the error message contains our custom content.
Responses That Throw ValueError#
Actually, this is because we raise a ValueError in our validation method, so Django Ninja automatically handles it for you.
A similar response occurs when the confirm password does not match:
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload"
],
"msg": "Value error, Password and password confirmation must match",
"ctx": {
"error": "Password and password confirmation must match"
}
}
]
}
What if we raise other types of errors, such as Django's ValidationError, or even a custom error we defined ourselves? Will Django Ninja still handle it automatically?
The answer is: No.
You will receive a "500 Internal Server Error"—the focus of the article after next.
Summary and Next Steps#
In this article, we introduced how to implement cross-field validation using @model_validator while putting the Separation of Concerns principle into practice.
After studying these two articles, your understanding of Django Ninja data validation has already surpassed that of most developers.
Next, we will explore in depth how to gracefully handle errors—and respond to them—when data validation fails, to improve the API user experience.