Pydantic & core / Chapter 2 of 5

Validation and serialization

Write Pydantic field and model validators, custom serializers, and computed fields. Handle validation errors and keep sensitive fields out of output.

9 topicsUpdated
13 / 44

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.

PythonAccept CSV tags and require a business email domain
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()])
TextExpected output
{'email': '[email protected]', 'tags': ['editor', 'reviewer']}
[(('email',), 'value_error'), (('tags', 0), 'string_type')]
Before validators should not mutate shared input

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.

14 / 44

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.

PythonRequire a checkout time after check-in
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'])
TextExpected output
45.0
() value_error
Model-level location

An 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.

15 / 44

Reusable validation rules and ordering#

Put a domain rule in an Annotated type and apply it consistently inside collections.

PythonUse one invoice-number rule in several places
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'])
TextExpected output
{'source_invoice': 'INV-42', 'related_invoices': ['INV-43']}
('related_invoices', 0) value_error

In 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.

PythonObserve before and after ordering
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']
TextExpected output
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.

16 / 44

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.

PythonTrim an imported identifier with a wrap validator
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__)
TextExpected output
WH-42
string_pattern_mismatch
str
Retrying validation

handler(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.

Special-purpose helpers

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.

17 / 44

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.

PythonRestrict warehouse selection and redact response fields by context
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'])
TextExpected output
{"warehouse":"DH-1","internal_note":"[redacted]"}
{"warehouse":"DH-1","internal_note":"Priority buyer"}
value_error
Context is not authorization by itself

Create 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.

18 / 44

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.

PythonKeep credentials out of an account response
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')
TextExpected output
{"username":"mira","api_key":"**********"}
{"username":"mira"}
Credential exclusion verified
Masking and exclusion

SecretStr 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().

PythonUnderstand subclass serialization before enabling it
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())
TextExpected output
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'}}
Subclass fields can expose more than intended

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.

PythonSelect fields inside every item of a response list
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))
TextExpected output
{'customers': [{'name': 'Mira'}]}

Official reference: Safe serialization of sensitive fields.

19 / 44

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.

PythonSerialize a Decimal balance and add an envelope summary
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')
TextExpected output
Decimal
{'owner': 'Mira', 'balance': '125.50', 'currency': 'BDT', 'kind': 'wallet'}
{"owner":"Mira","balance":"125.50","currency":"BDT","kind":"wallet"}
PythonReuse a UTC timestamp serializer
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())
TextExpected output
{"action":"invoice.sent","at":"2026-09-30T06:00:00Z"}
Serialization is not validation

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.

20 / 44

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.

PythonCompute the total on an invoice line
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))
TextExpected output
29.70
{"unit_price":"9.90","quantity":3,"total":"29.70"}
Input fields: ['quantity', 'unit_price']
Caching and mutation

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.

21 / 44

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.

PythonBuild a safe error response with JSON Pointer paths
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'
TextExpected output
{
  "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"
    }
  ]
}
Error redaction

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.

Union error locations

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.