Topic 15.4
Data Validation with Pydantic
In one line
Pydantic turns type hints into real checks: you describe the shape of your data as a class, and it converts and validates incoming dictionaries or JSON, collects clear errors, and can produce a JSON Schema. FastAPI, the AI SDKs and agent frameworks are built on it.
Think of it like this
A security guard at a building entrance with a list in hand. Each visitor says who they are and which floor they want. The guard checks the list: is the name there, is the floor a real number, is it a floor this visitor may enter? A visitor who says "floor 7" in writing is quietly accepted as floor 7; one who says "floor purple" is turned away with a note saying exactly what was wrong. Pydantic is the guard for data entering your program.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Validation
- Checking that data has the right shape, types and values.
- BaseModel
- The Pydantic class you inherit from to describe a data shape.
- Field
- One named piece of data in a model, with a type, maybe a default and constraints.
- Coercion
- Safely converting a value to the declared type, such as the string "7" to the integer 7.
- ValidationError
- The exception Pydantic raises, holding every problem it found.
- Serialisation (dump)
- Turning a model back into a plain dictionary or JSON text.
- Validator
- A function you attach to a field or model to check or clean a value.
- JSON Schema
- A standard way to describe the shape of JSON data, as JSON.
- Nested model
- A model used as the type of a field in another model.
Step by step
01Describe the data as a class
Fields are class attributes with type hints. str | None = None means optional text; list[str] = [] is a list defaulting to empty (Pydantic copies the default for each instance, so the shared-mutable-default trap of ordinary functions does not occur here).
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str | None = None
tags: list[str] = []02Create an instance: validate and convert
Keyword arguments go through validation. The string "7" becomes 7. A value that cannot be converted raises ValidationError. Extra keys that the model does not declare are ignored by default (you can forbid them with model_config = ConfigDict(extra="forbid")).
u = User(id="7", name="Asha")
print(u) # id=7 name='Asha' email=None tags=[]
print(type(u.id)) # <class 'int'>03Read the errors
All problems arrive together. Each error has loc, type, msg and input. Print only the pieces you need; str(e) also works but its text includes a link to the Pydantic docs that contains the library version (for example https://errors.pydantic.dev/2.12/v/int_parsing), so your output differs on other versions.
try:
User(id="seven", name=123, tags="python")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"], err["msg"])04Nest models and validate JSON
A model can contain other models and lists of them. The next runnable example builds a chat request like those that AI APIs accept: a list of Message objects. A wrong role deep inside is reported with the path ('messages', 0, 'role').
05Constraints with Field
Field(default=1.0, ge=0, le=2) says: a number from 0 to 2 inclusive. Field(min_length=1) rejects empty text. Literal["system", "user", "assistant"] accepts exactly those three words. All of these appear in the generated JSON Schema (minimum, maximum, enum), so a language model reading the schema knows the limits too.
06Custom validators
A @field_validator("model") runs after the type check. It receives the value and returns the cleaned value or raises ValueError, which Pydantic turns into a normal error entry (Value error, ...). The @classmethod line is required under the validator.
from pydantic import BaseModel, field_validator
class Request(BaseModel):
model: str
@field_validator("model")
@classmethod
def clean(cls, value: str) -> str:
value = value.strip().lower()
if " " in value:
raise ValueError("model names cannot contain spaces")
return value07Dump and schema
model_dump() gives a dict for any model, including nested ones. model_dump(exclude_none=True) omits unset fields, mode="json" converts values such as dates to JSON-safe types. model_json_schema() returns a dict that follows JSON Schema, with properties and required.
Try it yourself
- 1
Pass what was valid
In the first example change the failing call to
User(id="7", name="Asha", tags=["a", "b"]). No error: Pydantic accepted a string id and converted it. Now tryid="7.5"and read the error type. - 2
Strict versus lax
Add
from pydantic import ConfigDictandmodel_config = ConfigDict(strict=True)insideUser. Run it and seeUser(id="7", ...)now fail: strict mode refuses conversions. - 3
Add a rule
In the second example add a validator to
Messagethat rejects content longer than 20 characters, then send a 25-character message and read the error path.
Code & diagrams
Pydantic runs in your browser. The error "type" strings are stable codes; the "msg" text can change slightly between Pydantic versions.
Expected output
id=7 name='Asha' email=None tags=[]
id is int
{'id': 7, 'name': 'Asha', 'email': None, 'tags': []}
{"id":7,"name":"Asha","email":null,"tags":[]}
error count: 3
('id',) | int_parsing | Input should be a valid integer, unable to parse string as an integer
('name',) | string_type | Input should be a valid string
('tags',) | list_type | Input should be a valid list
id=9 name='Ben' email=None tags=[]
id=10 name='Cy' email=None tags=['a']This is the shape of a request to an AI chat API: a model name, a list of messages (each with a role and some text) and settings. `temperature` is a dial for how adventurous the model's wording is (low is steady, high is varied). Try temperature=1.5 and a valid role to see it pass.
Expected output
model='fake-model-1' messages=[Message(role='user', content='Hi')] temperature=1.0 max_tokens=None
{"model": "fake-model-1", "messages": [{"role": "user", "content": "Hi"}], "temperature": 1.0}
('model',) -> Value error, model names cannot contain spaces
('messages', 0, 'role') -> Input should be 'system', 'user' or 'assistant'
('messages', 0, 'content') -> String should have at least 1 character
('temperature',) -> Input should be less than or equal to 2
['max_tokens', 'messages', 'model', 'temperature']
['model', 'messages']
{'default': 1.0, 'maximum': 2, 'minimum': 0, 'title': 'Temperature', 'type': 'number'}Not runnable here: pydantic-settings is a separate package that is not in the browser.
# pip install pydantic-settings
from pydantic import SecretStr
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
llm_api_key: SecretStr
timeout_seconds: float = 10.0
settings = Settings() # reads LLM_API_KEY from the environment
print(settings) # the key prints as '**********'
headers = {"Authorization": "Bearer " + settings.llm_api_key.get_secret_value()}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to validate and trust a type hint
Use a plain class with type hints and pass it bad data.
class Order:
quantity: int
def __init__(self, quantity):
self.quantity = quantity
order = Order("many")
print(order.quantity + 1)Myth vs fact
Myth
Pydantic only checks types.
Fact
It also converts values (in lax mode), enforces constraints (ranges, lengths, patterns, allowed words), runs your own validators, and produces schemas and serialised output.
Myth
Type hints already protect my data at run time.
Fact
Python itself ignores them. Pydantic (and tools like it) is what turns the same hints into real run-time checks.
Myth
A model's output from an AI is valid JSON, so I can use it directly.
Fact
Models sometimes return text that is not JSON or does not match your schema. Always validate with model_validate_json and handle the ValidationError (Topic 16.6).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Pydantic v2 (the current line) moved the core into Rust (
pydantic-core). Names changed from v1:model_dumpreplaces.dict(),model_validatereplacesparse_obj,field_validatorreplacesvalidator. Old tutorials and some libraries still show v1 names. - ▸
Choose strict or lax per use. External JSON often has numbers as strings, so lax is handy. For internal boundaries,
ConfigDict(strict=True)orStrictIntcatches sloppy conversions. Useextra="forbid"on request models to catch misspelt field names instead of silently ignoring them. - ▸
Validation is not free, and
model_validate_jsonis faster thanjson.loadsfollowed bymodel_validatebecause it parses directly in Rust. For hot loops with huge numbers of tiny objects, plain dataclasses orTypedDictplus a single validation at the edge may be better. - ▸
Validation runs when a model is created, not when you change it.
user.id = "oops"succeeds silently unless you setConfigDict(validate_assignment=True).Model.model_construct(...)skips validation entirely (it is for trusted data you already checked), andmodel_copy(update={...})does not re-validate the update either. Interviewers like this one, because "it's a Pydantic model so it is valid" is only true at construction. - ▸
Lax mode still has rules. For
int,"7","7.0"and7.0are accepted but7.5and"7.5"are not, and evenTruebecomes1, so test the exact inputs your API sees. Forstr, a number is not converted (name=123fails, as the first runnable example shows). Forbool, the strings"true","yes","1","on"and their opposites are accepted, but"maybe"is not. The conversion table is in the docs, and per-callModel.model_validate(data, strict=True)switches it off without changing the class. - ▸
@field_validatorruns after Pydantic's own parsing by default (mode="after"), sovaluealready has the declared type. Usemode="before"to accept messy input first (for example turning a comma-separated string into a list), and@model_validator(mode="after")for checks across fields (such asend > start). RaiseValueErrororAssertionErrorinside validators; Pydantic turns them into aValidationErrorentry. Do not raiseValidationErroryourself.model_json_schema()lists a field underrequiredonly if it has no default, which matters when a language model must fill every field. - ▸
dataclassescan be validated withpydantic.dataclassesor aTypeAdapter, which validates any type hint, such asTypeAdapter(list[int]).validate_python(["1", 2]), without writing a model.
Remember this
- 1
Data from outside your program (an API reply, a form, a file, a language model's text) is untrusted: it may be missing fields, have the wrong types, or be nonsense. Python's type hints (Topic 10.1) are not enforced at run time, so a hint alone protects nothing. Pydantic reads your type hints and actually enforces them at the boundary, the place where data enters your program from outside. A schema is the written description of what valid data looks like: which fields, which types.
- 2
You describe the data as a class that inherits from
BaseModel(Topic 8.5: a subclass gets everything its parent class has), listing fields with type hints.str | Nonemeans "text or nothing" (Topic 10.2). Creating an instance (User(id="7", name="Asha")) validates and converts the values: the string"7"becomes the int7(Pydantic's default "lax" mode accepts safe conversions). A field with a default (= None,= []) is optional. Fields without one are required. - 3
When the data is wrong, Pydantic raises **
ValidationError**. It does not stop at the first problem: it collects all of them.e.errors()returns a list of dictionaries, each withloc(where, as a path such as('messages', 0, 'role')),type(a stable machine-readable code likeint_parsing),msg(a human sentence) andinput. This is what lets an API reply with a precise 422 error (Topic 15.5), or lets your code feed the exact error back to a language model so it can fix its own output (Topic 16.6). - 4
Models nest: a field can be another model, or a
listof them, and validation goes all the way down, with the path in each error. Usemodel_validate(dict)for a dictionary andmodel_validate_json(text)for JSON text;model_dump()gives back a plain dictionary andmodel_dump_json()JSON text.exclude_none=Truedrops unset optional fields, which is useful for API request bodies. - 5
Constraints go in
Field(...):ge/gt/le/ltfor numbers,min_length/max_length/patternfor text and lists. Usetyping.Literal["a", "b"]or anEnumfor fixed choices. For rules the built-ins cannot express, write a validator with@field_validator("name")on a@classmethod(decorators are Topic 9.6, class methods Topic 8.3; the validator can clean a value or raiseValueError), or@model_validatorto check fields against each other. - 6
Model.model_json_schema()produces a JSON Schema describing the model. This is why every AI framework uses Pydantic: the same class that validates data also tells a language model exactly which JSON to produce (tool definitions, structured output) and validates what comes back. FastAPI uses the schema for its automatic documentation. Settings can be read from the environment into a typed class with the separatepydantic-settingspackage (Topic 15.3 showed the manual version). Pydantic v2 is written largely in Rust (a fast systems language), so validation is fast.
Explain it without notes
What does Pydantic do that type hints alone do not?
What does a ValidationError contain, and why is that useful?
How do you add a rule that built-in constraints cannot express?
Why is model_json_schema() important for AI applications?
What is the difference between lax and strict mode, and is a Pydantic object always valid?
Practice
Create a model Product with name (non-empty text), price (a number above 0) and in_stock (boolean defaulting to True). Show that Product(name="Pen", price="2.5") works.
Show the error loc and type when price is -1.
Add a validator so name is stored with the first letter capitalised and surrounding spaces removed.
Trade-offs
- ↔
Pydantic models versus dataclasses. Dataclasses are standard library and light but do not validate. Pydantic validates and converts, but adds a dependency and some overhead. Use Pydantic where data enters from outside; use dataclasses for trusted internal values.
- ↔
Lax versus strict. Lax accepts "7" for an int, which is convenient for JSON and forms but can hide mistakes. Strict is safer but rejects data that is merely formatted differently.
Done when you can
Done when you can define a
BaseModelwith required, optional and defaulted fields.Done when you can read
e.errors()and say whatloc,typeandmsgmean.Done when you can nest models and validate JSON text with
model_validate_json.Done when you can add
Fieldconstraints and afield_validator.Done when you can produce
model_json_schema()and explain why AI tools need it.