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
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 eThere 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 helpThe 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 outputStructured 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"}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.",
)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,
})# 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"},
]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 pagePydantic 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 fieldPydantic 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]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 partThe 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 whyFor 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 endOne 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'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 theseSettings#
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 setLet 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: ...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 pageNow 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
3hTake 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
3hDefine 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.
Resources
Free to watch and read, picked for this topic.
Videos
For validating at the boundary
For pydantic and type checkers in practice
Full courses: the whole topic, start to finish
Stop 7 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.