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 .data dict 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_id must 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.

Loading diagram...

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 role to the four canonical values, raising ValueError (which Pydantic wraps in ValidationError).
  • Lines 26–34 — the content validator accepts info: ValidationInfo to read the already-validated max_tokens from info.data; declaring max_tokens before content in the class body is what makes this field-ordering dependency work.
  • Lines 36–40 — the mode='after' model validator runs once on self, enforcing the cross-field rule that tool messages must carry a tool_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

  1. Do return the value — every validator must return (the original or a transformed value); a missing return silently sets the field to None.
  2. Do put cross-field rules in a model validatormode='after' on @model_validator gives you a fully-constructed self so the rule reads naturally.
  3. Do normalise messy input with mode='before' — clean once at the boundary so downstream validators and application code can assume canonical shapes.

Don'ts

  1. Don't read info.data for a field declared later — Pydantic validates in declaration order, so the value isn't there yet; reorder fields or use a model validator.
  2. Don't swallow errors — raise ValueError with a specific message; Pydantic wraps it in a ValidationError that callers can inspect and act on.
  3. Don't reverse the decorator order@field_validator comes 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

All free lessons in GenAI Agent Engineering