Single-Field Validation#

This is the 19th article in the Django Ninja series. Entering Chapter 5: Data Validation and Error Handling.
Data validation is one of the key requirements in API development. It is responsible for ensuring that the data submitted by the client meets expectations, thereby preventing potential errors and security issues.
Effective data validation can provide timely and friendly responses when the API receives incorrect data, improving system stability and user experience.
In Django Ninja, the core tool for data validation is Pydantic. It provides powerful validation capabilities, which not only check data types but also easily implement custom validation.
This article will introduce how to use Pydantic to implement custom validation for a single field in Django Ninja; the next article will cover custom validation across multiple fields.
All code changes in this article can be referred to in this PR.
GitHub Example Project#
Chapter 5 Overview#
Data validation is important, and when validation fails, the program often throws validation errors. How to handle these errors effectively is the scope of "error handling."
This chapter will explore these two closely related topics, consisting of 4 articles:
- Part 19: Data Validation (Part 1) — Pydantic Single-Field Validation (this article)
- Part 20: Data Validation (Part 2) — Pydantic Cross-Field Validation
- Part 21: Error Handling (Part 1) — HttpError and Custom HTTP Responses
- Part 22: Error Handling (Part 2) — Global Error Handling Using Exception Handlers
In the first two articles, we will learn how to implement flexible data validation to ensure input data meets expectations, raising errors when necessary.
In the last two articles, we will discuss how to handle various errors that may arise in the API flow (not limited to validation errors) to provide a better user experience.
Compared with Django REST framework, Django Ninja's Data Validation and error-handling mechanisms are more complex, so each deserves a full article and a careful explanation.
API Fixes#
We will use the newly created API from the previous article—Create User—as our example.
We will continue to improve it, adding custom validation to make the data sent by the client more reliable.
First, however, I need to fix two errors. Here is the corrected code:
@router.post('/users/', summary='Create User', response={201: dict})
def create_user(...) -> tuple[int, dict]:
"""
Create User
"""
user = User(
username=payload.username,
email=payload.email,
bio=payload.bio,
)
# Encrypt the password using the set_password method
user.set_password(raw_password=payload.password)
user.save()
return 201, {'id': user.id, 'username': user.username}
We mainly corrected these two parts:
- Added the
response={201: dict}parameter to therouterdecorator. It was not defined originally, which would cause an error when actually using this API. Because by default, only a 200 response is allowed, if you want responses other than 200, you must declare it using theresponseparameter. - Used the
set_passwordmethod to encrypt the password entered by the user. This is a built-in Django feature that prevents passwords from being stored in plaintext in the database. Not storing passwords in plaintext is undoubtedly a fundamental practice of modern development.
With the fixes completed, let's officially enter the main topic.
Different "Levels" of Validation#
Since it is validation, it is naturally related to the requests from clients—validating request content.
In Django Ninja, each API can describe the data structure it receives by defining a Schema. These Schemas are based on Pydantic and can automatically validate the data in requests.
Type hints in Schemas can validate data types, which is the most basic validation.
The Pydantic Field mentioned in the previous article can validate characteristics such as length and range of data. This part will be demonstrated later.
These are mostly "formal" validations, and this article will focus on more complex "custom validation" based on specific rules.
Current Schema State of the Example API#
Taking "Create User" as an example, the request body receives fields like username, email, password, and bio. Through the Schema we defined, the most basic data type validation can be completed.
As mentioned in the previous article, only the bio field is optional, and the rest are required—missing them will result in a 422 response. Therefore, the Schema also validates the "existence" of data.
Looks pretty good so far! But we are not satisfied with just that.
New Requirement: Password Rules#
We require users to adhere to the following two rules when setting a password:
- Password length must be at least 8 characters.
- Must contain at least one number.
These rules help improve account security and prevent users from setting passwords that are too simple.
For educational purposes, I did not make the rules overly complex. Both rules carry specific educational significance:
- The minimum length limit can be implemented directly through Pydantic Field without custom coding.
- The second rule is the highlight, where we will use Pydantic's
@field_validatordecorator to define our own field validation rules.
Implementing Password Rule Validation: Using field_validator#
According to the requirements, we can first use Pydantic's Field to set the minimum length limit:
As shown above, we only need to add a min_length=8 parameter.
As for the validation of "must contain at least one number," we need to implement it using the @field_validator decorator.
The field_validator Decorator#
In Pydantic v1, this decorator was named validator; it was renamed to field_validator in v2.
From Pydantic v1 to v2, there are many breaking changes—such as the example parameter turning into examples as mentioned earlier. This is worth noting.
Below is the modified Schema, where we focus only on the field_validator section:
class CreateUserRequest(Schema):
...
password: str
...
@field_validator('password')
@classmethod
def validate_password_contains_number(cls, v: str) -> str:
"""
Validate that the password contains at least one number
"""
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one number')
return v
Key Points#
- The
field_validatordecorator must use parameters, and the valid values are field names (e.g.,password). - Although not demonstrated in the example, it can be applied to multiple fields.
- The syntax is
@field_validator('field1', 'field2', ...). You can even write@field_validator('*')to apply it to all fields. - Note, however, that these fields will execute the same validation logic, so they should theoretically be logically similar fields.
- The validation method name can be customized; you can name it whatever you like, as long as it is easy to understand.
- This is because Pydantic primarily looks at the field name on the decorator.
- This is very different from the naming convention in Django REST framework, which uses
validate_<field_name>. - The naming convention for parameters in Pydantic validation methods is
v, whereas in Django REST framework, it isvalue. - Convention 2: The validation method returns the input value as-is upon success and raises an error upon failure.
- Pydantic's validation method is a "class method," so the first parameter is
cls. Notably, you can omit the@classmethoddecorator because Pydantic handles it internally. - However, the Official Documentation still recommends using
@classmethod, and we will follow that recommendation. - If the
@classmethoddecorator is declared, it must be placed closest to the validation method.
Those few lines contain a surprising number of important details!
Practical Testing#
Testing the case where the password length is insufficient, the result is:
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
},
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"confirm_password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
}
]
}
This is the error raised by the Field check, and the response status code is 422.
Next, testing the case where the password does not contain numbers:
{
"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"
}
}
]
}
This counts as a "semi-custom" error response, as the structure is still determined by Django Ninja, but the error message part is defined by us.
Customizing error responses can be even more flexible, but this is the subject of the next-to-next article, "Error Handling (Part 1) — HttpError and Custom HTTP Responses," where we will discuss it in detail.
Summary#
In this article, we learned how to perform data validation on a single field using Pydantic, implementing password strength check rules.
In the next article, we will continue this topic and implement more complex cross-field validation.