Validation and serialization
Write Pydantic field and model validators, custom serializers, and computed fields. Handle validation errors and keep sensitive fields out of output.
Field validators: before and after#
Normalize raw input first; check domain rules after the type is known.
@field_validator(..., mode="before") receives raw input of any type. Return the transformed value for normal validation to check. The default mode="after" receives a value that passed the field’s type checks. Always return the value. Raise ValueError or PydanticCustomError for an invalid input.
from pydantic import BaseModel, EmailStr, field_validator, ValidationError
class Invitation(BaseModel):
email: EmailStr
tags: list[str]
@field_validator('tags', mode='before')
@classmethod
def split_csv(cls, value):
if isinstance(value, str):
return [part.strip() for part in value.split(',') if part.strip()]
return value
@field_validator('email')
@classmethod
def allowed_domain(cls, value):
if value.rsplit('@', 1)[1] != 'journal.org':
raise ValueError('use your journal.org email address')
return value
invite = Invitation(email='[email protected]', tags='editor, reviewer')
print(invite.model_dump())
try:
Invitation(email='[email protected]', tags=[123])
except ValidationError as exc:
print([(e['loc'], e['type']) for e in exc.errors()]){'email': '[email protected]', 'tags': ['editor', 'reviewer']}
[(('email',), 'value_error'), (('tags', 0), 'string_type')]Return a new dictionary or list when transforming input. A mutated value can reach another union branch after a failure. TypeError raised by a validator is not converted into a Pydantic validation error; treat it as a programming mistake. Avoid assert for user checks because optimized Python can remove it.
Pydantic calls the decorated methods during validation. tags runs before list validation; email runs after EmailStr normalization. @classmethod passes the model class as cls. Use a model validator for rules involving several fields.
Official reference: Field validators: before and after.
Model validators and relationships between fields#
Check a booking’s complete, typed state after its individual fields pass.
An after @model_validator is an instance method and returns self. A before model validator receives raw data, which might be a dictionary or an attribute-based object. For field validators, info.data contains only earlier successfully validated fields; use an after model validator when a rule depends on several fields.
from typing import Self
from pydantic import BaseModel, AwareDatetime, model_validator, ValidationError
class Booking(BaseModel):
check_in: AwareDatetime
check_out: AwareDatetime
@model_validator(mode='after')
def sensible_period(self) -> Self:
if self.check_out <= self.check_in:
raise ValueError('check_out must be after check_in')
return self
booking = Booking(check_in='2026-10-01T14:00:00+06:00',
check_out='2026-10-03T11:00:00+06:00')
print((booking.check_out - booking.check_in).total_seconds() / 3600)
try:
Booking(check_in='2026-10-03T14:00:00+06:00',
check_out='2026-10-01T11:00:00+06:00')
except ValidationError as exc:
print(exc.errors()[0]['loc'], exc.errors()[0]['type'])45.0
() value_errorAn after model failure has an empty loc at that model’s root. Keep the message clear, or place a one-field rule on that field when you need a field-specific location. Keep validators free of side effects; instance revalidation and generic validation can run them more than once.
Official reference: Model validators and relationships between fields.
Reusable validation rules and ordering#
Put a domain rule in an Annotated type and apply it consistently inside collections.
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, AfterValidator, Field, ValidationError
def trim(value):
return value.strip() if isinstance(value, str) else value
def require_invoice_prefix(value: str) -> str:
if not value.startswith('INV-'):
raise ValueError('invoice number must start with INV-')
return value
InvoiceNumber = Annotated[
str, Field(min_length=5), BeforeValidator(trim), AfterValidator(require_invoice_prefix)
]
class Reconciliation(BaseModel):
source_invoice: InvoiceNumber
related_invoices: list[InvoiceNumber]
record = Reconciliation(source_invoice=' INV-42 ', related_invoices=['INV-43'])
print(record.model_dump())
try:
Reconciliation(source_invoice='INV-42', related_invoices=['PO-43'])
except ValidationError as exc:
print(exc.errors()[0]['loc'], exc.errors()[0]['type']){'source_invoice': 'INV-42', 'related_invoices': ['INV-43']}
('related_invoices', 0) value_errorIn Annotated, before and wrap validators run from right to left, then after validators run from left to right. Decorator validators are appended after existing metadata and follow the same ordering. Make order visible when one operation depends on another.
from typing import Annotated
from pydantic import TypeAdapter, BeforeValidator, AfterValidator
events = []
def step(name):
def run(value):
events.append(name)
return value
return run
TrackedInt = Annotated[
int, AfterValidator(step('after-1')), AfterValidator(step('after-2')),
BeforeValidator(step('before-1')), BeforeValidator(step('before-2'))
]
print(TypeAdapter(TrackedInt).validate_python('5'))
print(events)
assert events == ['before-2', 'before-1', 'after-1', 'after-2']5
['before-2', 'before-1', 'after-1', 'after-2']The order is before-2, before-1, integer parsing, after-1, then after-2. A wrap validator surrounds the remaining checks.
Official reference: Reusable validation rules and ordering.
Wrap and plain validators#
Wrap the normal checks when you need control; use plain validation only when you own every check.
A wrap validator receives a handler that runs the remaining validation. Call it to preserve type and constraint checks; you can catch a specific failure and retry. A plain validator replaces the normal validation completely. Merely annotating a plain-validated field as int does not guarantee its returned value is an integer.
from typing import Annotated
from pydantic import TypeAdapter, WrapValidator, Field, ValidationError, PlainValidator
def imported_code(value, handler):
try:
return handler(value)
except ValidationError as exc:
errors = exc.errors()
if (isinstance(value, str) and len(errors) == 1
and errors[0]['type'] == 'string_pattern_mismatch'):
# One documented import repair; retry the normal checks.
return handler(value.strip().upper())
raise
WarehouseCode = Annotated[str, Field(pattern=r'^WH-[0-9]+$'), WrapValidator(imported_code)]
adapter = TypeAdapter(WarehouseCode)
print(adapter.validate_python(' wh-42 '))
try:
adapter.validate_python('not-a-warehouse')
except ValidationError as exc:
print(exc.errors()[0]['type'])
# Demonstrate the risk: normal int parsing has been replaced.
UncheckedInt = Annotated[int, PlainValidator(lambda value: value)]
print(type(TypeAdapter(UncheckedInt).validate_python('not an integer')).__name__)WH-42
string_pattern_mismatch
strhandler(value) runs the remaining checks. On a pattern error, the validator trims and uppercases the input, then checks it again. Other errors propagate. Use a before validator when normalization should always happen before checking.
SkipValidation[T] deliberately skips checks; dumping mismatched values can warn. InstanceOf[T] checks class membership. Raising PydanticUseDefault inside a before validator can request a declared default. None of these should silently weaken a public request contract.
Official reference: Wrap and plain validators.
Validation and serialization context#
Pass a trusted per-request policy without a global variable.
ValidationInfo.context holds the object supplied to model_validate(..., context=...) or a TypeAdapter. Serializers receive a separate serialization context supplied to dumping. Context is not saved in the model automatically. The normal Model(...) constructor has no context argument; prefer the explicit validation method.
from pydantic import BaseModel, ValidationInfo, field_validator, field_serializer, ValidationError
class Allocation(BaseModel):
warehouse: str
internal_note: str
@field_validator('warehouse')
@classmethod
def allowed_warehouse(cls, value: str, info: ValidationInfo) -> str:
policy = info.context or {}
if value not in policy.get('allowed_warehouses', set()):
raise ValueError('warehouse is not allowed for this request')
return value
@field_serializer('internal_note')
def visible_note(self, value, info):
return value if (info.context or {}).get('internal') else '[redacted]'
raw = {'warehouse': 'DH-1', 'internal_note': 'Priority buyer'}
allocation = Allocation.model_validate(raw, context={'allowed_warehouses': {'DH-1'}})
print(allocation.model_dump_json())
print(allocation.model_dump_json(context={'internal': True}))
try:
Allocation.model_validate(raw, context={'allowed_warehouses': {'CT-1'}})
except ValidationError as exc:
print(exc.errors()[0]['type']){"warehouse":"DH-1","internal_note":"[redacted]"}
{"warehouse":"DH-1","internal_note":"Priority buyer"}
value_errorCreate the context from authenticated application state, not from the caller’s payload. Database permissions and authorization still belong to your application. The example fails closed when validation context is missing; serialization defaults to redaction.
Validation and serialization receive separate context objects. Supplying validation context does not pass it to dumping. Redaction changes the output; the original note remains in the model.
Official reference: Validation and serialization context.
Safe serialization of sensitive fields#
Define a public response shape and explicitly exclude credentials.
SecretStr masks its value in normal representations and JSON output; it does not encrypt the value. Field(exclude=True) removes a field from dumps. A public response model is a stronger contract than repeatedly remembering an exclusion list. repr=False affects model display only, not serialization.
from pydantic import BaseModel, Field, SecretStr
class AccountRecord(BaseModel):
username: str
api_key: SecretStr
password_hash: str = Field(exclude=True, repr=False)
class PublicAccount(BaseModel):
username: str
account = AccountRecord(username='mira', api_key='demo-key', password_hash='demo-hash')
print(account.model_dump_json())
print(PublicAccount(username=account.username).model_dump_json())
assert 'password_hash' not in account.model_dump()
assert 'demo-key' not in account.model_dump_json()
print('Credential exclusion verified'){"username":"mira","api_key":"**********"}
{"username":"mira"}
Credential exclusion verifiedSecretStr leaves an api_key field with a masked value in JSON. The public model omits that field. The real value remains accessible through get_secret_value().
from pydantic import BaseModel, SerializeAsAny
class PublicUser(BaseModel):
username: str
class InternalUser(PublicUser):
internal_role: str
class Response(BaseModel):
user: PublicUser
response = Response(user=InternalUser(username='mira', internal_role='billing-admin'))
print('declared type:', response.model_dump())
print('polymorphic:', response.model_dump(polymorphic_serialization=True))
print('as any:', response.model_dump(serialize_as_any=True))
class FlexibleResponse(BaseModel):
user: SerializeAsAny[PublicUser]
print('field opt-in:', FlexibleResponse(user=response.user).model_dump())declared type: {'user': {'username': 'mira'}}
polymorphic: {'user': {'username': 'mira', 'internal_role': 'billing-admin'}}
as any: {'user': {'username': 'mira', 'internal_role': 'billing-admin'}}
field opt-in: {'user': {'username': 'mira', 'internal_role': 'billing-admin'}}By default, a nested model is serialized using its declared field type. Pydantic 2.13 adds polymorphic_serialization=True for model and Pydantic dataclass runtime schemas. SerializeAsAny/serialize_as_any=True is broader. Both can reveal subclass fields; opt in only after reviewing the actual response contents.
from pydantic import BaseModel
class Customer(BaseModel):
name: str
internal_id: int
class Batch(BaseModel):
customers: list[Customer]
note: str | None = None
batch = Batch(customers=[Customer(name='Mira', internal_id=7)])
print(batch.model_dump(exclude={'customers': {'__all__': {'internal_id'}}}, exclude_none=True)){'customers': [{'name': 'Mira'}]}Official reference: Safe serialization of sensitive fields.
Field serializers, model serializers, and reusable serializers#
Choose a wire representation without changing the stored Python value.
A plain serializer returns the replacement output. A wrap serializer calls the handler and can edit its result. when_used="json" keeps the original Python-mode value and customizes JSON-mode output. Annotated PlainSerializer/WrapSerializer makes the rule reusable. Only one serializer can be defined per field or model; compose work within it.
from decimal import Decimal
from pydantic import BaseModel, field_serializer, model_serializer
class Wallet(BaseModel):
owner: str
balance: Decimal
currency: str
@field_serializer('balance', when_used='json')
def balance_text(self, value: Decimal) -> str:
return format(value, '.2f')
@model_serializer(mode='wrap')
def with_kind(self, handler):
data = handler(self)
data['kind'] = 'wallet'
return data
wallet = Wallet(owner='Mira', balance='125.5', currency='BDT')
print(type(wallet.model_dump()['balance']).__name__)
print(wallet.model_dump(mode='json'))
print(wallet.model_dump_json())
assert wallet.balance == Decimal('125.5')Decimal
{'owner': 'Mira', 'balance': '125.50', 'currency': 'BDT', 'kind': 'wallet'}
{"owner":"Mira","balance":"125.50","currency":"BDT","kind":"wallet"}from datetime import timezone
from typing import Annotated
from pydantic import BaseModel, AwareDatetime, PlainSerializer
def utc_text(value) -> str:
return value.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
UTCTimestamp = Annotated[AwareDatetime, PlainSerializer(utc_text, return_type=str, when_used='json')]
class AuditEvent(BaseModel):
action: str
at: UTCTimestamp
event = AuditEvent(action='invoice.sent', at='2026-09-30T12:00:00+06:00')
print(event.model_dump_json()){"action":"invoice.sent","at":"2026-09-30T06:00:00Z"}The original model values remain unchanged. A serializer return annotation or return_type provides an output schema, but dumping arbitrary or corrupted objects should not be treated as request validation. Unknown values can raise PydanticSerializationError; a serializer callback that fails can also raise it.
The field serializer formats balance as a two-place string for JSON. The wrap serializer calls handler(self) and adds kind to the result. The stored balance remains a Decimal. Set rounding policy separately from output formatting.
Official reference: Field serializers, model serializers, and reusable serializers.
Computed fields and derived values#
Expose a value that can be calculated from validated fields.
@computed_field includes a property in model serialization and the serialization JSON Schema. It does not add an input field or validate the property’s calculation. Define the property’s return type. Exclude computed fields when building data intended to be validated as input again.
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, computed_field
class InvoiceLine(BaseModel):
model_config = ConfigDict(extra='forbid')
unit_price: Decimal = Field(gt=0, decimal_places=2)
quantity: int = Field(gt=0)
@computed_field
@property
def total(self) -> Decimal:
return self.unit_price * self.quantity
line = InvoiceLine(unit_price='9.90', quantity=3)
print(line.total)
print(line.model_dump_json())
input_data = line.model_dump(exclude_computed_fields=True)
assert InvoiceLine.model_validate(input_data) == line
print('Input fields:', sorted(input_data))29.70
{"unit_price":"9.90","quantity":3,"total":"29.70"}
Input fields: ['quantity', 'unit_price']A normal property recalculates each time. A cached_property can become stale after fields change; use it only when its dependencies stay unchanged or you invalidate it deliberately. round_trip=True is useful for round-trip-sensitive types such as Json[T]; it does not promise every custom serializer is reversible.
Official reference: Computed fields and derived values.
Structured validation errors and custom error codes#
Return actionable error paths without leaking raw request values.
ValidationError.errors() returns dictionaries with loc, type, msg, and usually the input, context, and a documentation URL. Keep type as a machine-readable code and use the path to associate an error with an input field. Exception text is for people; do not parse it to build an API response.
import json
from pydantic import BaseModel, Field, ValidationError, field_validator
from pydantic_core import PydanticCustomError
class Line(BaseModel):
sku: str
quantity: int = Field(gt=0)
@field_validator('sku')
@classmethod
def stock_prefix(cls, value: str) -> str:
if not value.startswith('BK-'):
raise PydanticCustomError('stock_prefix', 'Stock code must start with {prefix}', {'prefix': 'BK-'})
return value
class Order(BaseModel):
lines: list[Line]
def pointer(location):
return ''.join('/' + str(part).replace('~', '~0').replace('/', '~1') for part in location)
try:
Order.model_validate({'lines': [{'sku': 'XX-1', 'quantity': 0}]})
except ValidationError as exc:
response = {'errors': [
{'path': pointer(e['loc']), 'code': e['type'], 'message': e['msg']}
for e in exc.errors(include_input=False, include_context=False, include_url=False)
]}
print(json.dumps(response, indent=2))
assert response['errors'][0]['path'] == '/lines/0/sku'{
"errors": [
{
"path": "/lines/0/sku",
"code": "stock_prefix",
"message": "Stock code must start with BK-"
},
{
"path": "/lines/0/quantity",
"code": "greater_than",
"message": "Input should be greater than 0"
}
]
}Custom error messages and context can contain sensitive values; review both before returning them. Raw ctx may contain exceptions that json.dumps cannot encode. Reserve exc.json() for internal diagnostics.
Error locations may contain union branch labels as well as input keys and indexes. Keep them as error paths, or map them to actual input locations before presenting them as JSON Pointers. Aliases can also change reported field names.
Official reference: Structured validation errors and custom error codes.
Help improve CodeRef
Allow Google Analytics to measure page visits and use of the guides? It uses cookies after you accept. Your choice won’t affect access to the site. Privacy notice