Validators on Serializers¶
Pydantic's @field_validator and @model_validator can be declared directly on serializer configuration classes. The framework automatically collects these validators and applies them to the generated Pydantic schemas.
Use validators when:
- You need to enforce input constraints beyond what Django model fields provide (min length, format, cross-field logic)
- You want schema-level validation that runs before data touches the database
- You need different validation rules per operation (create vs. update)
ModelSerializer¶
Define validators directly on the inner serializer classes (CreateSerializer, ReadSerializer, UpdateSerializer, DetailSerializer):
from django.db import models
from ninja_aio.models import ModelSerializer
from pydantic import field_validator, model_validator
class User(ModelSerializer):
username = models.CharField(max_length=150)
email = models.EmailField()
age = models.PositiveIntegerField(default=0)
class CreateSerializer:
fields = ["username", "email", "age"]
@field_validator("username")
@classmethod
def validate_username(cls, v):
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
return v.lower()
@field_validator("age")
@classmethod
def validate_age(cls, v):
if v < 13:
raise ValueError("Must be at least 13 years old")
return v
class ReadSerializer:
fields = ["id", "username", "email"]
class UpdateSerializer:
optionals = [("username", str), ("email", str)]
@field_validator("username")
@classmethod
def validate_username_not_blank(cls, v):
if v is not None and len(v.strip()) == 0:
raise ValueError("Username cannot be blank")
return v
Import location
The @field_validator and @model_validator decorators must be imported inside each inner class, or at the model class level. Python's scoping rules require the decorator to be accessible where it is used.
Serializer (Meta-driven)¶
For Meta-driven Serializers, define validators on inner classes named CreateValidators, ReadValidators, UpdateValidators, or DetailValidators:
from ninja_aio.models import serializers
from pydantic import field_validator, model_validator
from . import models
class UserSerializer(serializers.Serializer):
class Meta:
model = models.User
schema_in = serializers.SchemaModelConfig(
fields=["username", "email", "age"]
)
schema_out = serializers.SchemaModelConfig(
fields=["id", "username", "email"]
)
schema_update = serializers.SchemaModelConfig(
optionals=[("username", str), ("email", str)]
)
class CreateValidators:
from pydantic import field_validator
@field_validator("username")
@classmethod
def validate_username(cls, v):
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v.lower()
class UpdateValidators:
from pydantic import field_validator
@field_validator("username")
@classmethod
def validate_username_not_blank(cls, v):
if v is not None and len(v.strip()) == 0:
raise ValueError("Username cannot be blank")
return v
Validators Class Mapping¶
| Schema Type | Validators Class |
|---|---|
schema_in |
CreateValidators |
schema_update |
UpdateValidators |
schema_out |
ReadValidators |
schema_detail |
DetailValidators |
Supported Validator Types¶
@field_validator¶
Validates individual fields. Runs during schema instantiation.
from pydantic import field_validator
class CreateSerializer:
fields = ["email", "username"]
@field_validator("email")
@classmethod
def validate_email_domain(cls, v):
if not v.endswith("@company.com"):
raise ValueError("Only company emails allowed")
return v
Modes:
| Mode | Description |
|---|---|
"after" |
Runs after Pydantic's type validation (default) |
"before" |
Runs before type coercion |
"wrap" |
Wraps the default validation |
"plain" |
Replaces default validation entirely |
@field_validator("age", mode="before")
@classmethod
def coerce_age(cls, v):
"""Accept string ages and convert to int."""
if isinstance(v, str):
return int(v)
return v
@model_validator¶
Validates the entire model after all fields are set. Useful for cross-field validation.
from pydantic import model_validator
class CreateSerializer:
fields = ["password", "email"]
customs = [("password_confirm", str)]
@model_validator(mode="after")
def check_passwords_match(self):
if hasattr(self, 'password_confirm') and self.password != self.password_confirm:
raise ValueError("Passwords do not match")
return self
Modes:
| Mode | Description |
|---|---|
"after" |
Runs after all field validators (receives model instance) |
"before" |
Runs before field validation (receives raw dict) |
"wrap" |
Wraps the entire validation process |
Different Validators per Operation¶
A key advantage is applying different validation rules per operation. Create might enforce stricter rules while update allows partial changes:
class Article(ModelSerializer):
title = models.CharField(max_length=200)
content = models.TextField()
status = models.CharField(max_length=20, default="draft")
class CreateSerializer:
fields = ["title", "content"]
@field_validator("title")
@classmethod
def validate_title(cls, v):
if len(v) < 10:
raise ValueError("Title must be at least 10 characters")
return v
@field_validator("content")
@classmethod
def validate_content(cls, v):
if len(v) < 50:
raise ValueError("Content must be at least 50 characters")
return v
class UpdateSerializer:
optionals = [("title", str), ("content", str), ("status", str)]
@field_validator("status")
@classmethod
def validate_status_transition(cls, v):
allowed = {"draft", "review", "published", "archived"}
if v not in allowed:
raise ValueError(f"Status must be one of: {', '.join(allowed)}")
return v
How It Works¶
Validators are processed during schema generation:
- When
generate_create_s(),generate_read_s(), etc. are called, the framework collects anyPydanticDescriptorProxyinstances (created by@field_validator/@model_validator) from the corresponding configuration class - After
ninja.orm.create_schema()generates the base Pydantic schema, a subclass is created with the validators attached - Pydantic discovers the validators during class creation and registers them normally
This means validators behave exactly as they would on a regular Pydantic model — including error formatting, mode handling, and validator ordering.
Error Handling¶
Validation errors are automatically caught and returned as structured API responses with status code 422:
{
"detail": [
{
"type": "value_error",
"loc": ["body", "username"],
"msg": "Value error, Username must be at least 3 characters"
}
]
}
No additional error handling configuration is needed.
Complete Example¶
from django.db import models
from ninja_aio.models import ModelSerializer
from pydantic import field_validator, model_validator
class Product(ModelSerializer):
name = models.CharField(max_length=200)
sku = models.CharField(max_length=50, unique=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class CreateSerializer:
fields = ["name", "sku", "price", "stock"]
@field_validator("sku")
@classmethod
def validate_sku_format(cls, v):
if not v.startswith("PRD-"):
raise ValueError("SKU must start with 'PRD-'")
return v.upper()
@field_validator("price")
@classmethod
def validate_price_positive(cls, v):
if v <= 0:
raise ValueError("Price must be greater than zero")
return v
class ReadSerializer:
fields = ["id", "name", "sku", "price", "stock", "is_active"]
customs = [
("in_stock", bool, lambda obj: obj.stock > 0),
]
class UpdateSerializer:
optionals = [
("name", str),
("price", float),
("stock", int),
("is_active", bool),
]
excludes = ["sku"] # SKU cannot be changed
@field_validator("price")
@classmethod
def validate_price_positive(cls, v):
if v is not None and v <= 0:
raise ValueError("Price must be greater than zero")
return v
@model_validator(mode="after")
def validate_stock_active_consistency(self):
"""Cannot activate a product with zero stock."""
if (
getattr(self, "is_active", None) is True
and getattr(self, "stock", None) == 0
):
raise ValueError("Cannot activate a product with zero stock")
return self
Schema Method Overrides¶
In addition to validators, you can define schema method overrides on the same inner classes.
These are regular methods (not decorated with @field_validator or @model_validator) that
override Pydantic schema methods like model_dump, model_validate, or add custom properties.
ModelSerializer¶
Define methods directly on the inner serializer classes:
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ninja import Schema
class User(models.Model, ModelSerializer):
name = models.CharField(max_length=255)
email = models.EmailField()
class ReadSerializer:
fields = ["id", "name", "email"]
def model_dump(
self: Schema,
*,
mode: str = "python",
include: Any = None,
exclude: Any = None,
context: Any = None,
by_alias: bool = False,
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
round_trip: bool = False,
warnings: bool | str = True,
serialize_as_any: bool = False,
) -> dict[str, Any]:
data = super().model_dump(
mode=mode,
include=include,
exclude=exclude,
context=context,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
round_trip=round_trip,
warnings=warnings,
serialize_as_any=serialize_as_any,
)
data["display_name"] = f"{data['name']} <{data['email']}>"
return data
Serializer (Meta-driven)¶
Define methods on the validator inner classes:
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ninja import Schema
class UserSerializer(serializers.Serializer):
class Meta:
model = User
schema_out = serializers.SchemaModelConfig(
fields=["id", "name", "email"]
)
class ReadValidators:
def model_dump(
self: Schema,
*,
mode: str = "python",
include: Any = None,
exclude: Any = None,
context: Any = None,
by_alias: bool = False,
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
round_trip: bool = False,
warnings: bool | str = True,
serialize_as_any: bool = False,
) -> dict[str, Any]:
data = super().model_dump(
mode=mode,
include=include,
exclude=exclude,
context=context,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
round_trip=round_trip,
warnings=warnings,
serialize_as_any=serialize_as_any,
)
data["display_name"] = f"{data['name']} <{data['email']}>"
return data
IDE autocomplete for overridden methods
By annotating self: Schema (with Schema imported under TYPE_CHECKING), your
IDE will provide full autocomplete and type checking for all Pydantic BaseModel
attributes and methods inside the override. The TYPE_CHECKING guard ensures
Schema is never imported at runtime — inner serializer/validator classes are
plain Python classes, not Pydantic models.
No automatic argument hinting
Inner serializer/validator classes are plain Python classes — not Pydantic models — so
IDEs cannot automatically infer parameter names or types for overridden methods like
model_dump. You must write out the full signature manually. Consult the
Pydantic BaseModel API reference
for the correct parameter signatures.
Validators and method overrides coexist
You can mix @field_validator, @model_validator, and method overrides on the same
inner class. The framework collects them separately — validators via Pydantic's
descriptor protocol, method overrides as regular callables — and applies both to the
generated schema subclass. Bare super() calls work correctly.
See Also¶
-
Model Serializer — Base class approach with auto-binding
-
Serializer (Meta-driven) — External serializer for vanilla models
-
APIViewSet — Auto-generated CRUD endpoints