Pydantic
Type hints validate nothing at runtime. Pydantic does — and it is now the de-facto data layer for FastAPI, LLM outputs, and config in modern Python.
What you'll learn
- Why type hints alone are silent at runtime
- BaseModel, Field constraints, and the v2 validator decorators
- Serializing with model_dump / model_dump_json and parsing with model_validate
- When to use Pydantic versus a plain dataclass
Before you start
A Python type hint is documentation, not a guarantee. Annotate a parameter age: int, pass it "42", and nothing complains. Pydantic is the library that takes those hints seriously and turns them into runtime behaviour: it validates (checks that incoming data matches your types and constraints), coerces (turns "42" into 42 when that is safe), and serialises (converts typed objects back to dicts or JSON). The point of all three is one idea — bad data should fail as it enters your system, at the HTTP boundary or the file read or the LLM response, not three functions later with a stack trace that points nowhere useful.
Pydantic v2 is the data layer underneath FastAPI, LangChain, Instructor, and most LLM SDKs. You will meet it everywhere, so it is worth knowing well.
The problem it solves
def signup(email: str, age: int):
# the type hints are NOT checked at runtime
print(f"Signing up {email}, age {age}")
signup("not-an-email", "definitely not a number")
Signing up not-an-email, age definitely not a number
Python accepted obvious garbage without a murmur — and your downstream code will be the one that blows up, several calls away, with a confusing trace. Pydantic moves that failure to the entry point and makes it loud.
BaseModel — your first model
from pydantic import BaseModel, Field, ValidationError
class Signup(BaseModel):
email: str = Field(pattern=r"[^@]+@[^@]+")
age: int = Field(ge=13, le=120)
name: str = Field(min_length=1, max_length=50)
# A valid payload parses cleanly.
user = Signup(email="aarav@datarekha.com", age=27, name="Aarav")
print(user)
# An invalid payload raises ValidationError with every failure at once.
try:
Signup(email="not-an-email", age=9, name="")
except ValidationError as e:
print(e.error_count(), "validation errors:")
for err in e.errors():
print(" ", err["loc"][0], "->", err["type"])
email='aarav@datarekha.com' age=27 name='Aarav'
3 validation errors:
email -> string_pattern_mismatch
age -> greater_than_equal
name -> string_too_short
Two things to notice. Field(ge=13, le=120) adds constraints beyond the type — greater-or-equal 13, less-or-equal 120 — and Field(pattern=...) and min_length do the same for strings. And the error reports every failing field at once (three of them here), not just the first — which is exactly what you want when returning a 422 to a frontend so the user can fix everything in one pass. (We printed a tidy summary; the full print(e) gives a detailed multi-line report.)
Custom validation — @field_validator
When a rule will not fit in Field, write a validator. It either returns the (possibly transformed) value or raises ValueError:
from pydantic import BaseModel, field_validator, ValidationError
class Order(BaseModel):
sku: str
quantity: int
@field_validator("sku")
@classmethod
def sku_must_be_upper(cls, v: str) -> str:
if not v.isupper():
raise ValueError("sku must be uppercase")
return v # always return the validated value
@field_validator("quantity")
@classmethod
def quantity_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("quantity must be positive")
return v
try:
Order(sku="abc-123", quantity=-1)
except ValidationError as e:
print(e.error_count(), "errors:")
for err in e.errors():
print(" ", err["loc"][0], "->", err["msg"])
2 errors:
sku -> Value error, sku must be uppercase
quantity -> Value error, quantity must be positive
The @classmethod and the cls parameter are required in Pydantic v2 — that detail tripped up a lot of people during the v1-to-v2 migration. Note again that both fields were checked, not just the first.
Cross-field rules — @model_validator
When a rule spans several fields, use @model_validator(mode="after"), which runs once the individual fields are validated and self is a real typed instance:
from pydantic import BaseModel, model_validator
from typing import Self
class DateRange(BaseModel):
start: int # epoch seconds, for the demo
end: int
@model_validator(mode="after")
def end_after_start(self) -> Self:
if self.end <= self.start:
raise ValueError("end must be after start")
return self
print(DateRange(start=100, end=200)) # fine
try:
DateRange(start=200, end=100)
except Exception as e:
print(type(e).__name__)
for err in e.errors():
print(" ", err["msg"])
start=100 end=200
ValidationError
Value error, end must be after start
Use mode="after" (the common case) when your rule reads the typed fields; reach for mode="before" only when you need to inspect or rewrite the raw input dict before field validation runs.
Serialising and parsing
You will move between dicts/JSON and models constantly, and the v2 method names are worth memorising:
from pydantic import BaseModel
class Product(BaseModel):
id: int
name: str
price: float
p = Product(id=1, name="Headphones", price=199.99)
print(p.model_dump()) # to a dict — for passing to other code
print(p.model_dump_json(indent=2)) # to a JSON string — for HTTP, logs, files
data = {"id": 2, "name": "Cable", "price": 9.5}
print(Product.model_validate(data)) # from a dict — the input boundary
raw = '{"id": 3, "name": "Mouse", "price": 25}'
print(Product.model_validate_json(raw)) # from a JSON string
{'id': 1, 'name': 'Headphones', 'price': 199.99}
{
"id": 1,
"name": "Headphones",
"price": 199.99
}
id=2 name='Cable' price=9.5
id=3 name='Mouse' price=25.0
Notice the last line: the JSON had "price": 25 (an integer), and Pydantic coerced it to the float 25.0 to match the model. The pattern is always the same — validate at the boundary, work with typed models inside, dump back to JSON at the exit. In v1 these methods were .dict(), .json(), and .parse_obj(); if you find those in old code, you are looking at Pydantic v1.
Strict mode — when coercion bites
By default Pydantic coerces, so "42" becomes 42 for an int field — usually right at an HTTP boundary, where query strings arrive as text. When you need exact types, opt into strict mode:
from pydantic import BaseModel, ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(strict=True)
age: int
StrictUser(age="42") # raises in strict mode — no string-to-int coercion
Use strict mode for internal data that should already be typed by the time it reaches the model; leave it off at HTTP boundaries, where coercion is a feature.
In one breath
- Type hints are silent at runtime; Pydantic validates, coerces, and serialises against them.
Field(ge=..., min_length=..., pattern=...)adds constraints; aValidationErrorreports all failures at once.@field_validatorchecks one field,@model_validator(mode="after")checks across fields.model_dump/model_dump_jsongo out;model_validate/model_validate_jsoncome in.- Coercion is on by default — use
strict=Truefor internal data that is already typed.
Practice
Quick check
What’s next
Pydantic’s killer use case is not HTTP payloads — it is coaxing structured output from LLMs. Define a model, hand its JSON schema to the model, and parse the reply straight back into typed fields. That is Pydantic for LLM outputs, next.
Practice this in an interview
All questionsModern APIs offer constrained decoding — the model's token sampling is restricted to only produce tokens that are valid continuations of a JSON schema. Combined with Pydantic validation in application code, this eliminates the JSON-parsing errors that plagued earlier prompt-only approaches. When constrained decoding is unavailable, few-shot examples plus output parsing with retry is the fallback.
Data poisoning is an attack where an adversary injects malicious or mislabeled examples into the training data to bias the model, create backdoors, or degrade it, and it is hard to detect because the model still trains successfully. Loading a pickle model is dangerous because Python's pickle executes arbitrary code on deserialization, so a malicious .pkl or .pt file from an untrusted source can run attacker code the moment you load it. Defenses include trusted data provenance and validation, and using safe formats like safetensors plus scanning model files.
A data contract is an explicit, enforced agreement between a data producer and consumers that specifies schema, types, semantics, and quality or freshness expectations, plus rules for how it can evolve. It prevents silent breakage by validating data at ingestion so violations are caught and quarantined or alerted instead of flowing into the model. Combined with a schema registry and backward-compatible evolution rules, it lets producers change data without unexpectedly corrupting downstream features and predictions.
pandas merge supports inner, left, right, and outer joins that mirror SQL semantics. The validate parameter enforces key cardinality ('one-to-one', 'one-to-many', 'many-to-one', 'many-to-many') and raises MergeError immediately when the data violates the expectation, preventing silent row multiplication.