Free lesson · GenAI Agent Engineering
Write custom Pydantic validators
You can write @field_validator for single-field rules and @model_validator for cross-field invariants, choose mode='before' vs 'after' vs 'wrap', validate against regex patterns, and use Annotated types to attach validators inline.
Course: GenAI Agent Engineering · Chapter 4 · The Data Validator
Free to read — no subscription required.
Introduction
When you accept LLM payloads — chat messages, generation configs, tool responses — you cannot trust the shape Pydantic infers from types alone. A role arrives as "Human" instead of "user", a temperature is 1.5 when your model expects [0, 1], or a tool message omits its tool_call_id. Pushing that downstream produces malformed prompts, silent drift, or a 400 from the model API. Custom validators let you reject or repair these shapes at the boundary. By the end of this lesson you will be able to write field and model validators that enforce allowed values, transform inputs, and check cross-field invariants on Pydantic models.
Key Terminology
- Field validator — a method decorated with
@field_validator('name')that validates or transforms one (or several) named fields; runs per-field during model construction. - Model validator — a method decorated with
@model_validator(mode='after')that runs once on the constructed instance, used for rules that span multiple fields. mode='before'— runs the validator on raw input before type coercion; use it to normalise messy external data into the type Pydantic expects.ValidationInfo— the second parameter Pydantic can pass to a field validator; its.datadict holds already-validated peer fields so the validator can read them.- Cross-field invariant — a rule that only makes sense once more than one field is known (e.g. "if
role == 'tool',tool_call_idmust be set"); the natural home for a model validator.
Concepts
Field validators
A field validator targets one field (or a few named fields) and runs during construction. It can enforce allowed values, transform the value, or raise ValueError with a message Pydantic surfaces in its ValidationError. The method must return the (possibly modified) value — forgetting to return silently makes the field None. The decorator order is fixed: @field_validator first, then @classmethod. (See Code Walkthrough.)
Model validators
A model validator with mode='after' runs once on the fully-constructed instance, so it can read every field via self and enforce rules that span fields — for example, "tool_call_id is required when role == 'tool'", or "either temperature or top_p should be set, not both." Reach for it whenever the rule isn't a property of a single field.
mode='before' for normalising raw input
mode='before' validators receive raw input prior to type coercion, which is exactly what you need when external sources send "Human" for role, None for content, or a list of content blocks where you expect a string. Normalise once at the boundary, then let standard mode='after' validators enforce final constraints on clean data.
Reading peer fields with ValidationInfo
Sometimes a field's validity depends on another field — e.g. content can't exceed a max_tokens declared on the same model. A field validator can accept a second info: ValidationInfo parameter and read already-validated fields from info.data. Pydantic validates fields in declaration order, so dependencies must be declared before their dependents in the class body.
Code Walkthrough
Now that you've seen field validators, model validators, mode='before' for normalising raw input, and reading peer fields with ValidationInfo, this walkthrough turns them into working code.
The snippet below puts all four patterns on a single ChatMessage model: a mode='before' field validator normalises the role, a standard field validator restricts it to allowed values, a content validator reads the peer max_tokens via ValidationInfo, and a model validator enforces the cross-field tool-call rule.
Code snippet python
1from pydantic import BaseModel, field_validator, model_validator, ValidationInfo 2 3class ChatMessage(BaseModel): 4 max_tokens: int = 4000 5 role: str 6 content: str 7 tool_call_id: str | None = None 8 9 @field_validator('role', mode='before') 10 @classmethod 11 def normalize_role(cls, v): 12 if isinstance(v, str): 13 mapping = {'human': 'user', 'ai': 'assistant', 'bot': 'assistant'} 14 cleaned = v.lower().strip() 15 return mapping.get(cleaned, cleaned) 16 return v 17 18 @field_validator('role') 19 @classmethod 20 def role_in_allowed_set(cls, v: str) -> str: 21 allowed = {'user', 'assistant', 'system', 'tool'} 22 if v not in allowed: 23 raise ValueError(f"role must be one of {allowed}") 24 return v 25 26 @field_validator('content') 27 @classmethod 28 def content_fits_budget(cls, v: str, info: ValidationInfo) -> str: 29 if not v.strip(): 30 raise ValueError("content cannot be empty") 31 budget = info.data.get('max_tokens', 4000) 32 if len(v) / 4 > budget: 33 raise ValueError(f"content ~{int(len(v)/4)} tokens > budget {budget}") 34 return v.strip() 35 36 @model_validator(mode='after') 37 def tool_role_needs_call_id(self) -> 'ChatMessage': 38 if self.role == 'tool' and not self.tool_call_id: 39 raise ValueError("tool_call_id required when role == 'tool'") 40 return self
- Lines 9–16 — the
mode='before'validator maps"Human"/"AI"/"Bot"to canonical roles before type coercion, so the next validator receives a clean string. - Lines 18–24 — the standard field validator restricts
roleto the four canonical values, raisingValueError(which Pydantic wraps inValidationError). - Lines 26–34 — the
contentvalidator acceptsinfo: ValidationInfoto read the already-validatedmax_tokensfrominfo.data; declaringmax_tokensbeforecontentin the class body is what makes this field-ordering dependency work. - Lines 36–40 — the
mode='after'model validator runs once onself, enforcing the cross-field rule thattoolmessages must carry atool_call_id.
To confirm the validators wire together correctly, run ChatMessage(role='Human', content='hi') and verify the constructed object has role == 'user', then run ChatMessage(role='tool', content='x') and verify it raises ValidationError citing the missing tool_call_id.
Do's and Don'ts
Having walked through the material above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do return the value — every validator must return (the original or a transformed value); a missing
returnsilently sets the field toNone. - ✓Do put cross-field rules in a model validator —
mode='after'on@model_validatorgives you a fully-constructedselfso the rule reads naturally. - ✓Do normalise messy input with
mode='before'— clean once at the boundary so downstream validators and application code can assume canonical shapes.
Don'ts
- ✗Don't read
info.datafor a field declared later — Pydantic validates in declaration order, so the value isn't there yet; reorder fields or use a model validator. - ✗Don't swallow errors — raise
ValueErrorwith a specific message; Pydantic wraps it in aValidationErrorthat callers can inspect and act on. - ✗Don't reverse the decorator order —
@field_validatorcomes first, then@classmethod; swapping them silently breaks the validator.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime
More free lessons in GenAI Agent Engineering
- Ch 1Isolate your agent's Python environment
- Ch 1Secure API key management
- Ch 4Write custom Pydantic validatorsYou are here
- Ch 4Parse LLM output into Pydantic models
- Ch 4Configure agents with Pydantic Settings
- Ch 11Practical use cases — security, parameters, observability
- Ch 15Use Pydantic for tool schemas