Complete Reference Guide

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.

MC
Mayank Chugh
Senior Enterprise Architect · AI Engineer · RAG & Agentic AI
Foundations

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.

Analogy A recipe card pinned to a shared kitchen wall says "2 cups flour" — anyone can write "some flour" instead and nothing stops them. Pydantic is the difference between that recipe card and a customs declaration form: one is a suggestion, the other is checked at the border before anything is allowed through.

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.

Foundations

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.

Foundations

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.

Modeling Data

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.

Modeling Data

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
Watch out 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.
Modeling Data

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
Modeling Data

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.

Configuration

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().

Interactive

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.

model: Signup(BaseModel)
Pydantic + LLMs

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.

The retry pattern Validate the model's output against a 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.
Pydantic + LLMs

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
Pydantic + LLMs

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.

Wrap-up

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.