Mastering Pydantic
A practical, example-driven walkthrough of Pydantic — from your first BaseModel
to validated, self-correcting structured outputs from LLMs. Written for engineers who want the shape of
their data to be a guarantee, not a hope.
Why Pydantic Exists
Python is dynamically typed — a function can be handed a string where it expected an integer, and nothing complains until three calls deeper, in a stack trace that leads you nowhere near the actual mistake. Pydantic exists to close that gap: it turns type hints into enforced, runtime contracts.
Without validation, bad data doesn't fail loudly at the door — it fails quietly, three modules later,
as a TypeError no one can trace back to its source. Pydantic moves that failure
to the one place it's cheap to fix: the boundary.
Your First Model
A Pydantic model is a class where every attribute is a validated, typed field. Instantiate it with bad data, and it raises before your code ever sees it.
from pydantic import BaseModel
class User(BaseModel):
id: int
full_name: str
years_experience: int = 0
u = User(id="42", full_name="Mayank Chugh", years_experience=20)
print(u.id) # 42 (int, coerced)
print(u.model_dump()) # {'id': 42, 'full_name': 'Mayank Chugh', 'years_experience': 20}
Notice id="42" — a string — was coerced into an int. Pydantic
validates the shape, not just the raw type: it will accept anything it can safely convert, and
reject anything it can't.
BaseModel vs. dataclass vs. plain class
All three let you group typed attributes together. Only one of them actually checks the data at runtime.
Plain class
Type hints are pure documentation. Nothing is enforced — you can assign any value to any attribute.
@dataclass
Removes boilerplate (__init__, __repr__) but
still does zero validation by default.
BaseModel
Validates on construction, coerces compatible types, and gives you model_dump(),
model_json_schema(), and structured error messages for free.
Rule of thumb: use BaseModel at every boundary where data enters
your system from the outside world — an API request, a config file, an LLM response.
Nested Models
Models can contain other models, and lists of models. Validation recurses all the way down — a bad value three levels deep still raises a precise, addressable error.
class Address(BaseModel):
city: str
state: str = "Maharashtra"
class Order(BaseModel):
order_id: str
items: list[str]
ship_to: Address
o = Order(order_id="ORD-001", items=["keyboard"], ship_to={"city": "Pune"})
print(o.ship_to.city) # "Pune"
This is the "org chart" pattern — each nested model owns and validates its own slice of the data, the same way a department head is responsible for their own team rather than the whole company.
Optional and Literal Types
Optional[X] says a field may be missing or None.
Literal[...] constrains a field to an exact, closed set of values — useful for
status codes, categories, or anything an LLM should be forced to pick from rather than invent.
from typing import Optional, Literal
class Ticket(BaseModel):
priority: Literal["low", "medium", "high"]
assignee: Optional[str] = None
assignee: Optional[str] = None makes the field optional to omit —
it does not make it optional to be the wrong type. Passing assignee=123 still fails.
Field Constraints & Custom Validators
Field(...) adds constraints — min/max length, numeric ranges, defaults —
directly on the type. When a rule is too specific for Field to express, you
reach for a validator.
field_validator — per-field custom logic
Check or transform the value of a single field, e.g. normalizing an email or rejecting disposable domains.
model_validator — cross-field rules
Check a rule that depends on two or more fields together, e.g. password must
equal confirm_password.
from pydantic import BaseModel, Field, field_validator, model_validator
class Signup(BaseModel):
email: str = Field(min_length=5)
password: str
confirm_password: str
@field_validator("email")
@classmethod
def no_disposable_domains(cls, v):
if v.endswith("@mailinator.com"):
raise ValueError("disposable domains not accepted")
return v
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("password and confirm_password do not match")
return self
Computed Fields & Serialization Control
@computed_field lets you calculate a brand-new value from other
fields and have it show up in model_dump() and the JSON schema, without storing
it redundantly.
from pydantic import BaseModel, computed_field
class Cart(BaseModel):
unit_price: float
quantity: int
@computed_field
@property
def total(self) -> float:
return round(self.unit_price * self.quantity, 2)
Pair this with Field(exclude=True) or a custom
SecretStr when a field should be validated but never leave the process
(API keys, password hashes) — the difference between shape and correctness, and what's safe to show
downstream.
Pydantic Settings
pydantic-settings extends the same validation model to configuration:
environment variables, .env files, and secrets — validated exactly like any
other model, instead of read ad hoc with os.environ.get().
from pydantic_settings import BaseSettings
from pydantic import SecretStr
class Settings(BaseSettings):
debug: bool = False
database_url: str
api_key: SecretStr
class Config:
env_file = ".env"
settings = Settings()
print(settings.api_key) # SecretStr('**********')
SecretStr masks the value in logs, reprs, and tracebacks —
it only reveals the raw string when you explicitly call
settings.api_key.get_secret_value().
Live Validation Playground
This mirrors the Signup model above. Try invalid input — a short email, an
empty name, mismatched passwords — and see the validation errors it produces.
Why Raw LLM Text Output Is a Liability
An LLM asked to "extract product info as JSON" will usually comply — until it doesn't: a missing brace, an invented field, a price returned as the string "around $40". Feeding that straight into your system is like taking a shouted order at a noisy counter instead of a printed slip: it mostly works, until the one time it doesn't and nobody notices.
BaseModel. On failure, feed the exact
ValidationError back to the model and ask it to retry — Pydantic becomes the
feedback loop that makes an LLM self-correcting, up to a bounded number of attempts before you fail loudly.
OpenAI Structured Outputs
Modern LLM APIs accept a Pydantic model directly as the target schema and guarantee (or closely
constrain) the response to match it — the same idea as response_model in
FastAPI, with a different vendor name.
from pydantic import BaseModel
from openai import OpenAI
class ProductInfo(BaseModel):
name: str
price_usd: float
in_stock: bool
client = OpenAI()
resp = client.responses.parse(
model="claude-sonnet-4-6",
input="Extract product info as JSON from: ...",
text_format=ProductInfo,
)
product: ProductInfo = resp.output_parsed
Response Models & Auto-Generated Docs
FastAPI is built directly on Pydantic: the same models used to validate a request body double as the
response_model, and both are introspected to generate live OpenAPI docs —
automatic request validation with zero hand-written schema.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class OrderOut(BaseModel):
order_id: str
total: float
@app.post("/orders", response_model=OrderOut)
def create_order(order: Order):
return OrderOut(order_id=order.order_id, total=42.0)
The request body, the response body, and the interactive docs at
/docs all come from one model definition — no separate schema to keep in sync.
Where Pydantic Goes From Here
This guide is deliberately out of scope on a few things — generic models, custom serializers per-field,
Pydantic's Rust core (pydantic-core), and schema migration strategies. What's
here is the 20% that covers most real systems: models, nesting, validators, settings, and the LLM boundary
where structured output stops being optional.
If this was useful, the rest of my AI engineering work — RAG pipelines, self-correcting agentic workflows, and MCP tooling — is linked below.