Pydantic

Runtime data validation and settings management powered by type annotations

Why

Pydantic is the de-facto standard for data validation in Python. FastAPI, one of the most popular web frameworks, is built on it. It powers settings management (via pydantic-settings), API request/response schemas, and configuration objects in data pipelines and ML systems.

In production code, Pydantic signals that you write robust, self-documenting data contracts rather than raw dicts. It turns type annotations from passive documentation into active enforcement — coercing and validating data at the boundary between your code and the outside world.

Intuition

Think of a Pydantic model as a dataclass that actually enforces its types. When you create an instance, Pydantic reads the annotations, coerces compatible inputs (e.g. "42"42 for an int field), and raises a detailed ValidationError if anything is wrong — before the bad data reaches your business logic.

Explanation
1. BaseModel — defining and instantiating models

A Pydantic model is a class with type-annotated fields. On instantiation, Pydantic validates and coerces input — invalid data raises a detailed ValidationError before any business logic runs.

from pydantic import BaseModel class User(BaseModel): id: int name: str email: str active: bool = True # default value u = User(id="1", name="Alice", email="alice@example.com") # id="1" (str) is coerced to 1 (int) print(u.id) # 1 print(u.active) # True print(u.model_dump()) # {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'active': True} u2 = User(id="not-a-number", name="Bob", email="b@b.com") # raises ValidationError: id must be an integer

Fields are defined with standard Python type annotations. Pydantic validates on construction — you cannot create an instance with invalid data, unlike a plain dataclass.

2. Field — constraints and metadata

Field() adds constraints beyond the type — minimum length, numeric bounds, regex patterns. It also carries metadata like descriptions that power auto-generated API docs.

from pydantic import BaseModel, Field class Product(BaseModel): name: str = Field(min_length=1, max_length=100) price: float = Field(gt=0, description="Price in USD") quantity: int = Field(default=0, ge=0) # ge = greater-than-or-equal p = Product(name="Widget", price=9.99) print(p.quantity) # 0 Product(name="", price=9.99) # ValidationError: min_length=1 Product(name="X", price=-1.0) # ValidationError: gt=0

Field() adds constraints (gt, ge, lt, le, min_length, max_length, pattern) and metadata like description and alias for JSON key mapping.

3. Nested models and Optional fields

Models can reference other models as fields. Pydantic automatically coerces a plain dict into the nested model type — ideal for parsing nested JSON API payloads with no manual instantiation.

from pydantic import BaseModel from typing import Optional class Address(BaseModel): street: str city: str postcode: str class Order(BaseModel): order_id: int customer: str shipping_address: Optional[Address] = None # can be None or an Address # Pydantic coerces nested dicts into the model automatically data = { "order_id": 42, "customer": "Alice", "shipping_address": {"street": "1 Main St", "city": "NYC", "postcode": "10001"} } order = Order(**data) print(order.shipping_address.city) # "NYC"

You can nest models freely. Passing a plain dict where a nested model is expected triggers automatic coercion — no manual instantiation needed. This makes Pydantic ideal for parsing JSON API payloads.

4. Validators — field_validator and model_validator

When type constraints aren't enough, validators let you transform or reject values with custom logic. Field validators run per field; model validators run after all fields are set, enabling cross-field checks.

from pydantic import BaseModel, field_validator, model_validator class SignupForm(BaseModel): username: str password: str confirm_password: str @field_validator("username") @classmethod def username_lowercase(cls, v: str) -> str: return v.lower() # transform the value @model_validator(mode="after") def passwords_match(self) -> "SignupForm": if self.password != self.confirm_password: raise ValueError("passwords do not match") return self form = SignupForm(username="Alice", password="s3cr3t", confirm_password="s3cr3t") print(form.username) # "alice" ← transformed SignupForm(username="Bob", password="abc", confirm_password="xyz") # ValidationError: passwords do not match

field_validator runs on a single field and can transform or reject the value. model_validator(mode="after") runs after all fields are set, giving access to the full model for cross-field checks.

5. model_config — strict mode, aliases, and JSON parsing

ConfigDict sets model-wide behaviour — how strictly types are enforced, how JSON keys map to Python field names, and automatic string cleanup.

from pydantic import BaseModel, ConfigDict, Field class ApiResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, # allow both alias and field name str_strip_whitespace=True, ) user_id: int = Field(alias="userId") # JSON key is camelCase full_name: str = Field(alias="fullName") # Parse from JSON-style dict with camelCase keys r = ApiResponse.model_validate({"userId": 7, "fullName": " Alice "}) print(r.user_id) # 7 print(r.full_name) # "Alice" ← whitespace stripped # Strict mode — no coercion class Strict(BaseModel): model_config = ConfigDict(strict=True) count: int Strict(count="5") # ValidationError — str not accepted for int in strict mode

strict=True disables coercion — a string won't silently become an int. alias maps a Python field name to a different JSON key. str_strip_whitespace trims incoming strings automatically before validation runs.

6. Serialisation — model_dump and model_dump_json

Pydantic models need to convert back out — to dicts for ORMs, to JSON strings for APIs. Both methods handle nested models, datetimes, and enums automatically.

from pydantic import BaseModel from datetime import datetime class Event(BaseModel): name: str created_at: datetime e = Event(name="Launch", created_at="2024-01-15T10:00:00") # Dict — great for passing to ORMs or other functions print(e.model_dump()) # {'name': 'Launch', 'created_at': datetime(2024, 1, 15, 10, 0)} # JSON string print(e.model_dump_json()) # '{"name":"Launch","created_at":"2024-01-15T10:00:00"}' # Exclude fields or use aliases in output print(e.model_dump(exclude={"created_at"})) # {'name': 'Launch'}

model_dump() returns a Python dict; model_dump_json() returns a JSON string. Both handle nested models, datetimes, enums, and custom types out of the box.