Back to the map
ProgrammingCore11h

Typing & validation

Checking that model output is what you claimed.

A type says what kind of value something is: a number, a piece of text, a record with particular fields. Validation is code that checks a value really has that shape before anything downstream trusts it. In Python, type hints write the expectation down and a library such as Pydantic enforces it while the program runs, turning an unchecked blob of data into an object whose fields are guaranteed to exist.

A model returns text, and text you asked to be JSON is still only text: a field can be missing, renamed, or invented outright. With no check at the boundary an invented value travels three layers into the system and surfaces as a strange bug a long way from its cause, usually as a wrong answer rather than a crash.

It builds on Python, and it is the boundary layer for everything a model hands you: structured output, tool calling and agents all depend on being able to say confidently what came back. It pairs with testing, which checks your own code, where this checks input you did not write.

Model output is untrusted input; check it once, at the door, in one place.

In more detailModel output is untrusted input. Pydantic models at the boundary turn a vague dict into a validated object and give you one place to handle malformed responses. Type hints throughout make the boundary between 'model said so' and 'we verified it' visible in the code.

Learn first

Section 1 of 2, 6h

Validating at the boundary

By the end: Untrusted data becomes trusted objects in exactly one place in your system.

  • Where validation belongs
  • Parsing model output safely
  • Schemas as the single source of truth
  • Failing usefully

There are a few points in any system where data crosses in from somewhere you do not control, and a model's reply is now one of them. This section is about checking it in exactly one place per edge and handing typed objects to everything inward of that, so nothing further in has to wonder whether a field is there. Scatter those checks and they drift apart, which is how one codebase ends up with three incompatible ideas of what a missing value means.

One place, at the edge#

Every system has boundaries where data arrives from somewhere you do not control: an HTTP request, a file, a database, a model response. Validate at each boundary and pass typed objects inward. Validating deeper means checking the same thing in several places, and those checks drift apart.

HTTP request  ->  validate  ->  typed object  ->  business logic
Model output  ->  validate  ->  typed object  ->  business logic

Past the arrow, nothing needs to ask whether a field exists.

Model output is untrusted input#

Treat a model response exactly as you would a form submission from a stranger. It is usually well formed and occasionally is not, and the failure should be caught where it arrives rather than three functions later.

from pydantic import BaseModel, Field, ValidationError

class Extraction(BaseModel):
    invoice_number: str
    amount: float = Field(gt=0)
    currency: str = Field(min_length=3, max_length=3)
    due_date: str | None = None        # genuinely optional

def extract(text: str) -> Extraction:
    raw = call_model(text)
    try:
        return Extraction.model_validate_json(raw)
    except ValidationError as e:
        raise ModelOutputError(f"could not parse extraction: {e.errors()}") from e
One conversion point. Everything downstream receives an Extraction or nothing.

There are four distinct ways that call can go wrong, and they want different handling. Knowing which one you have is the difference between a retry that works and a retry loop that burns money reaching the same answer.

What went wrong                          What it looks like              What to do
not JSON at all                          json_invalid, "Expecting value"  strip prose, then retry
JSON, wrong shape                        missing / extra field            retry with the error
right shape, impossible value            amount: -5, currency 'POUNDS'    retry with the error
right shape, plausible, wrong            invoice_number: 'INV-0001'       validation cannot help
The fourth row is the one to keep in mind. A schema proves structure and never truth.

The first row is common enough to handle rather than retry. A model asked for JSON frequently returns JSON wrapped in a sentence of explanation, or inside a fenced code block, and both parse as invalid. Strip that before validating, and reserve the retry for cases where there is genuinely no JSON in the reply.

import json, re

FENCE = chr(96) * 3          # the triple backtick a model wraps JSON in

def extract_json(text: str) -> str:
    """Return the first complete JSON value in a model reply, as a string."""
    t = text.strip()
    fenced = re.search(FENCE + r"(?:json)?\s*(.*?)" + FENCE, t, re.S)
    if fenced:
        t = fenced.group(1).strip()
    start = min((i for i in (t.find("{"), t.find("[")) if i != -1), default=-1)
    if start == -1:
        raise ValueError("no JSON object or array in model output")
    obj, _end = json.JSONDecoder().raw_decode(t[start:])   # ignores trailing prose
    return json.dumps(obj)

extract_json('Here is the JSON:\n{"amount": 12.5}\nHope that helps.')   # {"amount": 12.5}
extract_json(FENCE + 'json\n{"amount": 12.5}\n' + FENCE)                 # {"amount": 12.5}
extract_json("I am not able to help with that.")
# ValueError: no JSON object or array in model output
raw_decode reads one complete value and stops, which is what makes trailing prose harmless. All four outputs above are real.

Structured output does not remove the boundary

Constrained decoding and tool-use schemas make malformed JSON rare, and they change nothing about the other three rows. The value can still be out of range, the enum can still be a plausible-looking member you did not define if the schema allowed a free string, and nothing at all checks that the invoice number is the one on the invoice. Validate anyway: it costs microseconds and it is the only place a wrong value can be stopped.

One schema, two jobs#

The same model definition can generate the JSON schema you send to the API and validate the response that comes back. That is worth more than it sounds: the contract and the check cannot disagree, because they are the same declaration.

schema = Extraction.model_json_schema()

response = client.generate(prompt, response_schema=schema)
result = Extraction.model_validate_json(response.text)
{"properties": {"invoice_number": {"title": "Invoice Number", "type": "string"},
                "amount":  {"exclusiveMinimum": 0, "type": "number"},
                "currency": {"maxLength": 3, "minLength": 3, "type": "string"},
                "due_date": {"anyOf": [{"type": "string"}, {"type": "null"}],
                             "default": null}},
 "required": ["invoice_number", "amount", "currency"],
 "title": "Extraction", "type": "object"}
What model_json_schema() actually emits for the class above. Note that due_date is absent from required, which is the difference the model reads.

Two things follow. Give every field a description, because that text is carried into the schema and is the only instruction the model gets about what the field means; amount: float with no description is guessed at, while a description saying the total including tax, in the invoice's own currency is not. And keep the schema small. A schema with forty fields and deep nesting gets a worse fill rate than three calls with a dozen fields each, and it is harder to tell which part went wrong.

Optional means optional#

Required fields force invention

Marking a field required when the source might not contain it does not make the data appear. It makes the model invent a plausible value, and validation will happily accept it because it is the right type. Mark genuinely optional fields optional, and tell the model null is acceptable.

The same argument applies to the range checks. Field(gt=0) on an amount is worth having because a negative total is impossible and catching it is free. Field(le=500) on a refund is a different kind of rule: it is a policy limit, and it belongs in the model definition precisely because that is the one place every caller passes through. Where a genuine value might exceed it, a validator that flags for review is better than one that rejects, because a rejection here loses a real refund.

from typing import Literal

class Extraction(BaseModel):
    invoice_number: str = Field(description="As printed on the invoice, e.g. INV-20412")
    amount: float = Field(gt=0, description="Total including tax")
    currency: Literal["GBP", "USD", "EUR"]      # an enum, not a free string
    due_date: str | None = Field(
        default=None,
        description="ISO date. Null if the invoice does not state one.",
    )
Literal becomes an enum in the schema, so 'POUNDS' is rejected at the boundary rather than reaching a currency conversion three functions later.

Fail with something actionable#

try:
    extraction = Extraction.model_validate_json(response.text)
except ValidationError as e:
    for err in e.errors():
        field = ".".join(str(p) for p in err["loc"])
        log.warning("validation_failed", extra={
            "field": field,
            "problem": err["msg"],
            "run_id": run_id,
        })
errors() returns every problem, so a response with three faults reports all three.
# What errors() actually contains, for
# {"invoice_number": "INV-1", "amount": -5, "currency": "POUNDS"}
[
  {"type": "greater_than", "loc": ("amount",),
   "msg": "Input should be greater than 0", "input": -5},
  {"type": "string_too_long", "loc": ("currency",),
   "msg": "String should have at most 3 characters", "input": "POUNDS"},
]
Real output. loc is a tuple of the path, so a nested failure comes back as ('lines', 3, 'amount') and joins into a field name a human can find.

Log the type as well as the message, because type is a stable machine-readable code and msg is prose that changes between versions. Counting failures by field and type is what turns this from an error log into a signal: if ninety percent of your failures are currency, the prompt needs to name the three currencies, and no amount of retrying will discover that for you.

Do not log the whole invalid payload by default

The thing that failed validation is untrusted input, and it may contain a customer's personal details or an entire document. Log the field names, the error types and an id; keep the payload behind a flag, or store it in a review queue with the same access controls as the source data.

Finally, decide what failure means before it happens, and make it a route rather than an exception someone else catches. Retry once with the specific error fed back, then fail cleanly: store nothing, put the case in a review queue or return an explicit error, and record it. The one option that is never acceptable is continuing with partial data.

Where people go wrong

Catching a validation error and continuing with partial data. A half-valid object flowing onward causes damage somewhere unrelated and much later, which is far more expensive to diagnose than an immediate failure.

Build this: Take a model call that returns JSON and put a validated boundary around it. Then feed it three malformed responses and produce a distinct actionable message for each.

Open this section on its own page
Section 2 of 2, 5h

Pydantic and type checkers in practice

By the end: You can model real data with Pydantic, validate model output with useful errors, and let a type checker catch mistakes before they run.

  • Models, fields and defaults
  • Custom validators
  • Unions with a tag field
  • Validate, then retry with the error
  • Settings from the environment
  • Running a type checker

Model output arrives as text, and text can be anything: the wrong shape, the wrong type, a refund for fifty times what anyone authorised. This section is about the layer that stops all of that at the edge of your system, by declaring what the data must look like, adding the rules a type cannot express, deciding what happens when validation fails, and letting a type checker read the rest of the code for free. The alternative is a dictionary passed around the codebase that every function downstream has to guess about.

The smallest model#

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

User(name="Jack", age="31")    # works: "31" is converted to 31
User(name="Jack", age="old")   # raises ValidationError, and says which field

Pydantic checks and converts data when you create the object. After that line, the rest of your code can trust that age is an int.

1 validation error for User
age
  Input should be a valid integer, unable to parse string as an integer
  [type=int_parsing, input_value='old', input_type=str]
The real message. It names the field, what was expected, what arrived, and a stable type code you can count and branch on.

Three details about what it does with the data. It coerces where the conversion is unambiguous, so the string '31' becomes 31 and '1.5' becomes a float, but 1.7 into an int field is rejected rather than truncated, because discarding information silently is worse than failing. It ignores unknown fields by default, which is usually right for an API you do not control and wrong for model output, where an unexpected key means the model answered a different question; set extra='forbid' there. And it validates on construction only, so mutating an attribute afterwards skips every check unless you ask for validate_assignment.

class Strict(BaseModel):
    model_config = {"extra": "forbid", "validate_assignment": True}
    a: int

Strict.model_validate({"a": 1, "b": 2})
# ValidationError: b  Extra inputs are not permitted [type=extra_forbidden]

class Loose(BaseModel):
    a: int

Loose.model_validate({"a": 1, "b": 2})     # a=1   the b is silently dropped

class C(BaseModel):
    n: int
C(n="7")      # n=7,  coerced
C(n=1.7)      # ValidationError: Input should be a valid integer,
              #                  got a number with a fractional part
Real outputs. Choose extra='forbid' at a boundary where a surprise field is evidence of a problem, and the default where a provider may add fields you do not care about.

The three entry points do the same job on different input. model_validate takes a dict, model_validate_json takes a JSON string and is the one to use on a model response because it parses and validates in one step with a clearer error when the text is not JSON at all, and constructing the class directly validates keyword arguments. Going the other way, model_dump gives a dict and model_dump_json a string; model_construct skips validation entirely and exists for data you have already checked, not as a way round an error.

Rules that types cannot express#

from pydantic import BaseModel, Field, field_validator

class Refund(BaseModel):
    order_id: str = Field(pattern=r"^A-\d{3,}$")
    amount: float = Field(gt=0, le=500)
    reason: str

    @field_validator("reason")
    @classmethod
    def not_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("reason must say why")
        return v.strip()

Limits like 'at most 500' belong here, next to the data, not scattered through the code that uses it. A model asking for a refund of 5,000 is stopped at the boundary.

Two things about validators are worth knowing precisely. A validator returns the value, which means it can normalise as well as check: the one above strips whitespace, and that stripped value is what the object carries, so every caller downstream sees the tidy version. And it signals failure by raising ValueError, not by returning False and not by raising ValidationError, which pydantic constructs for you by collecting everything that failed.

Refund(order_id="A-102", amount=10, reason="  damaged  ")
# order_id='A-102' amount=10.0 reason='damaged'      note the strip

Refund(order_id="X-1", amount=5000, reason=" ")
# 3 validation errors:
#   order_id  String should match pattern '^A-\d{3,}$'
#   amount    Input should be less than or equal to 500
#   reason    Value error, reason must say why
Real output. All three problems come back at once, which is what makes a retry that feeds the error back worth doing: the model fixes everything in one attempt.

For a rule that spans several fields, a field validator cannot see the others, so use a model validator with mode='after', which runs once the whole object exists and is fully typed. mode='before' runs on the raw input instead and is the place to reshape something the model tends to get wrong, such as accepting a bare string where an object was asked for.

from pydantic import model_validator

class DateRange(BaseModel):
    start: str
    end: str

    @model_validator(mode="after")
    def ordered(self):
        if self.start > self.end:
            raise ValueError("start must not be after end")
        return self       # an 'after' validator returns self, not a value

DateRange(start="2026-05-01", end="2026-01-01")
# ValidationError: Value error, start must not be after end
Verified. The cross-field rules that matter in practice: a range ordered correctly, a discount not exceeding the total, an action that names a team only when it is an escalation.

One of several shapes#

Agents often return one of several actions. A tag field tells Pydantic which shape to expect, which gives far clearer errors than trying each shape in turn.

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, TypeAdapter

class Reply(BaseModel):
    type: Literal["reply"]
    text: str

class Escalate(BaseModel):
    type: Literal["escalate"]
    team: Literal["billing", "tech"]

Action = Annotated[Union[Reply, Escalate], Field(discriminator="type")]
action = TypeAdapter(Action).validate_json(model_output)

TypeAdapter is how you validate anything that is not itself a BaseModel: a union like this one, a list[Extraction], a plain dict[str, float]. Build it once and reuse it, because construction compiles the validator and is the expensive part.

ta = TypeAdapter(Action)

ta.validate_json('{"type": "escalate", "team": "billing"}')
# Escalate(type='escalate', team='billing')

ta.validate_json('{"type": "escalate", "team": "legal"}')
# ValidationError: escalate.team
#   Input should be 'billing' or 'tech'

ta.validate_json('{"type": "unknown"}')
# ValidationError: Input tag 'unknown' found using 'type' does not match any of
#                  the expected tags: 'reply', 'escalate'
Real errors. Without the discriminator, pydantic tries every member and reports why each one failed, which for four shapes is a wall of text that names the wrong problem first.

The same pattern is what a tool call is: a name that selects a shape, and arguments validated against that shape. Define one model per tool, union them on the name, and the dispatch becomes a match on the validated object rather than a chain of dictionary lookups that each assume a key exists.

Validate, then retry with the error#

from pydantic import ValidationError

def get_refund(messages, attempts=2):
    for _ in range(attempts):
        raw = call_model(messages)
        try:
            return Refund.model_validate_json(raw)
        except ValidationError as e:
            messages = messages + [
                {"role": "assistant", "content": raw},
                {"role": "user", "content": f"That failed validation: {e}. Fix it."},
            ]
    raise ExtractionFailed(raw)

The error message is specific, so the model usually fixes it in one go. Cap the attempts and count how often the retry happens.

Four things decide whether this helps or just costs twice as much. Feed back the error, not a generic instruction to try again; the errors() output names the field and the constraint, and that is the whole reason the second attempt succeeds. Cap attempts at two, because a third almost never converges and you are now paying three times for a document that needs a human. Do not retry a refusal: if the model declined, the input is the problem and the same prompt will decline again. And record the retry rate per field, because a field that needs a retry on a fifth of documents is a prompt or schema problem that a retry loop is quietly paying to work around for ever.

outcome              share   what it costs        what it means
valid first try      0.91     1 call               working
valid after retry    0.07     2 calls + latency    watch which field
failed after 2       0.02     2 calls, no result   review queue
refused              0.00     1 call               do not retry these
Report it in this shape after any extraction change. A first-try rate that drops after a prompt edit is the fastest signal you have that the edit was wrong.

Settings#

The pydantic-settings package reads configuration from environment variables into a typed object and fails at startup if something is missing. A missing API key should stop the service from starting, not surface as an error on the first request an hour later.

from pydantic import Field
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    anthropic_api_key: str                       # no default: required
    model: str = "claude-sonnet-5"
    max_concurrency: int = Field(default=8, ge=1, le=64)
    request_timeout_s: float = 30.0

    model_config = {"env_file": ".env"}

settings = Settings()      # raises here, at import, if the key is not set
Field names map to environment variables case-insensitively, so ANTHROPIC_API_KEY fills the first. A typo in MAX_CONCURENCY is then a missing variable with a default rather than a silent zero.

Let a type checker read your code#

Pydantic checks data at runtime. A static type checker, such as mypy or pyright, checks your code without running it: passing a str where an int is expected, forgetting that a value can be None. Run one in CI. It catches a class of bugs that tests often miss, especially the None case.

The two are complementary and neither substitutes for the other. A type hint is a claim about your own code that costs nothing at runtime and is not enforced there; pydantic is an actual check on data you did not write. So hints go everywhere inside the system and pydantic goes at the edges, which is the same division the previous section drew between trusted and untrusted.

def cheapest(models: list[str]) -> str | None:
    ...

best = cheapest(available)
print(best.upper())
# mypy: error: Item "None" of "str | None" has no attribute "upper"

# Three annotations worth writing by hand:
from typing import Literal, TypedDict

Role = Literal["user", "assistant"]          # not str
class Message(TypedDict):                     # a dict with known keys
    role: Role
    content: str

def run(messages: list[Message]) -> str: ...
Start with mypy in non-strict mode on new files only, or the first run on an existing codebase produces four hundred errors and gets turned off.

Where people go wrong

Calling json.loads on model output and passing the dict around. Every function downstream then has to guess what is in it.

Build this: Model the output of an agent with at least three action types as a discriminated union with field limits. Add a validate-and-retry loop, run it on thirty inputs, and report the first-try and after-retry success rates.

Open this section on its own page

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Annotate an existing codebase

3h

Take something you wrote untyped. Add annotations and run a type checker until clean. Keep a note of every error that was a real bug rather than a missing annotation.

Done when: The checker is clean and you can name at least one genuine bug it found.

2A validated boundary around a model call

3h

Define a pydantic model, generate its JSON schema, send that as the response schema, and validate the response with the same class. Then feed it three malformed responses.

Done when: One class definition drives both the request contract and the validation, and each failure produces an actionable message.

6 hours of practice

Interview questions

5 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Validating at the boundary

Where should validation happen?

At every boundary where data enters from somewhere you do not control, and only there. Past that point everything downstream can assume the shape is correct, which keeps the checks in one place instead of scattered and drifting.

Why is model output untrusted input?

It is generated rather than guaranteed. It is usually the shape you asked for and sometimes is not, and a schema only guarantees structure, never that the values are real. Treat it like a form submission from a stranger.

What is the benefit of generating a JSON schema from your validation model?

The contract you send to the API and the check you run on the response come from one declaration, so they cannot drift apart. Change the shape once and both follow.

Pydantic and type checkers in practice

Where should validation happen in an AI application?

At the boundaries: when data enters from users, from other services, and from the model. Validate once at the edge into typed objects, and let the inside of the system trust them.

Then they push further

Why treat model output like user input?

Because it can be anything. It can be malformed, out of range, or shaped by text an attacker put in a document. It needs the same checks as anything else that crosses a trust boundary.

Validation fails. What does your system do?

Retry once with the specific error passed back, then fail cleanly: store nothing, send the case to a review queue or return a clear error, and log it. Never store a guess.

What gets marked up. Marked up for treating model output as untrusted input and for a defined path when validation fails.

What is a discriminated union and when would you use one?

A union of shapes with a tag field that says which shape an object is. It suits outputs that can be one of several actions, and gives precise errors because the validator knows which shape to check.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

Stop 7 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeAsync & concurrencyWaiting on many slow calls at once.10h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.