Every non-trivial Python program has a boundary where untrusted data enters: an API request body, a config file, an environment variable, a row from a CSV. The naive approach is to trust the shape of that data and let KeyError or TypeError surface deep inside your business logic when it doesn’t match. Pydantic moves that failure to the boundary, where it belongs, and gives you a typed, validated object instead of a bag of dicts and hope.

What it is

Pydantic is a data validation library built on top of Python’s type hints. You describe the shape of your data as a class with annotated fields, and Pydantic handles parsing, validation, and coercion, then raises a single clear error listing everything wrong with the input if validation fails.

It’s not just a validation library either. Pydantic v2 (a full rewrite with a Rust core, pydantic-core) is fast enough to use on hot paths, and it has become the de facto standard for data modeling in the Python web ecosystem: FastAPI is built on it, and it shows up in config management, LLM tool-calling schemas, and data pipelines.

Install

pip install pydantic

No extra dependencies required for the core library. Optional extras exist for things like email validation (pydantic[email]) and settings management (pydantic-settings), which we’ll use below.

Killer feature: declarative models with automatic validation

Instead of writing manual if checks and raise ValueError calls, you declare a BaseModel subclass with typed fields, and Pydantic validates on construction:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    is_active: bool = True

user = User(id="123", name="Alice")
print(user)
# id=123 name='Alice' is_active=True
print(type(user.id))
# <class 'int'>

Notice id="123" (a string) got coerced to int because that’s a sensible, unambiguous conversion. Pass something that can’t be coerced, and you get a structured error instead of a crash somewhere downstream:

User(id="not-a-number", name="Alice")
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
id
  Input should be a valid integer, unable to parse string as an integer
  [type=int_parsing, input_value='not-a-number', input_type=str]

One error, one clear message, pointing at exactly the field that failed. That’s the whole pitch: push validation to the edge of your system and let the rest of your code assume the data is correct.

Real-world pattern: validating API request bodies

This is Pydantic’s most common home. Whether you’re using FastAPI (which does this automatically) or wiring it into another framework by hand, the pattern is the same: define the expected shape, parse the incoming payload, and reject anything that doesn’t fit before it touches your business logic.

from pydantic import BaseModel, Field, EmailStr
from datetime import datetime

class CreateOrderRequest(BaseModel):
    customer_email: EmailStr
    items: list[str] = Field(min_length=1)
    quantity: int = Field(gt=0)
    notes: str | None = None

def handle_request(raw_body: dict):
    try:
        order = CreateOrderRequest.model_validate(raw_body)
    except ValidationError as e:
        return {"error": e.errors()}, 400

    create_order(order.customer_email, order.items, order.quantity)
    return {"status": "created"}, 201

Field(gt=0) and Field(min_length=1) add constraints beyond basic types, so “quantity is a positive integer” and “at least one item” are enforced without a single manual if. EmailStr (from the email extra) rejects malformed email addresses at parse time.

Real-world pattern: application settings from environment variables

pydantic-settings extends the same modeling approach to configuration, reading from environment variables (and .env files) with the same validation guarantees:

pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    database_url: str
    debug: bool = False
    max_connections: int = 10

settings = Settings()
print(settings.database_url)

This replaces the common os.environ.get("MAX_CONNECTIONS", "10") pattern, followed by a manual int() cast, followed by hoping nobody sets MAX_CONNECTIONS=banana in production. With BaseSettings, a malformed environment variable fails fast at startup with a clear error instead of causing a mysterious TypeError three requests later.

Real-world pattern: nested models for structured data

Real-world payloads are rarely flat. Pydantic models nest naturally, and validation cascades through the whole structure:

from pydantic import BaseModel

class Address(BaseModel):
    street: str
    city: str
    postal_code: str

class Customer(BaseModel):
    name: str
    address: Address
    tags: list[str] = []

data = {
    "name": "Bob",
    "address": {"street": "123 Main St", "city": "Springfield", "postal_code": "62704"},
    "tags": ["vip", "wholesale"],
}

customer = Customer.model_validate(data)
print(customer.address.city)
# Springfield

If address is missing a required field, the error message tells you exactly where in the nested structure the problem is (address.postal_code, for example), which matters a lot once you’re debugging a payload with a dozen nested objects instead of three.

Real-world pattern: custom validators for business rules

Type and range constraints cover a lot, but sometimes a field needs a rule that doesn’t fit Field(). The field_validator decorator lets you write arbitrary validation logic that still plugs into the same error-collection machinery:

from pydantic import BaseModel, field_validator

class SignupRequest(BaseModel):
    username: str
    password: str

    @field_validator("username")
    @classmethod
    def username_must_be_alphanumeric(cls, value: str) -> str:
        if not value.isalnum():
            raise ValueError("username must be alphanumeric")
        return value

    @field_validator("password")
    @classmethod
    def password_must_be_strong(cls, value: str) -> str:
        if len(value) < 8:
            raise ValueError("password must be at least 8 characters")
        return value

Because these are just methods, they compose with everything else Pydantic already validates (types, presence, nested models), so one ValidationError at the end still lists every problem across the whole model, not just the first one hit.

Real-world pattern: serializing back out with model_dump

Validation is only half the story. Once you have a validated model, turning it back into a dict or JSON for a response, a database write, or a log line is a method call, and you can shape the output without extra plumbing:

order = CreateOrderRequest(customer_email="a@b.com", items=["widget"], quantity=2)

order.model_dump()
# {'customer_email': 'a@b.com', 'items': ['widget'], 'quantity': 2, 'notes': None}

order.model_dump(exclude={"notes"})
# {'customer_email': 'a@b.com', 'items': ['widget'], 'quantity': 2}

order.model_dump_json()
# '{"customer_email":"a@b.com","items":["widget"],"quantity":2,"notes":null}'

This is the common round-trip in a web service: validate untrusted input into a model, operate on the model with full type safety and autocomplete, then dump it back to a dict or JSON string at the next boundary (a database driver, an HTTP response, a message queue).

The ecosystem built on top of Pydantic

Pydantic’s model layer turned out to be such a solid foundation that a whole generation of libraries adopted it instead of inventing their own validation system. A few worth knowing:

  • FastAPI is the biggest example. Request bodies, query parameters, and response models are all plain Pydantic models, and FastAPI uses them to validate incoming requests, serialize responses, and generate an OpenAPI schema and interactive docs, all from the same class you’d write anyway:

    from fastapi import FastAPI
    from pydantic import BaseModel
    
    app = FastAPI()
    
    class Item(BaseModel):
        name: str
        price: float
    
    @app.post("/items")
    def create_item(item: Item) -> Item:
        return item
    

    No separate schema definition, no manual OpenAPI wiring. The validation you get for free is the documentation.

  • SQLModel, from the same author as FastAPI, merges Pydantic models with SQLAlchemy tables, so one class definition serves as both your database model and your API schema instead of maintaining two parallel definitions that drift apart.

  • LangChain and other LLM tooling frameworks use Pydantic models to define structured output schemas and tool/function-calling signatures. The model’s fields (and their descriptions) get turned into the JSON schema sent to the LLM, and the LLM’s response gets validated back into the same model.

  • Litestar, an alternative to FastAPI, similarly leans on Pydantic (or msgspec) for request and response modeling.

  • Typer, while primarily built on type hints rather than Pydantic directly, comes from the same ecosystem philosophy and composes cleanly with Pydantic models for CLI tools that also need to validate structured config.

The common thread: once your data has a typed, validated shape, a lot of adjacent problems (docs generation, database mapping, schema generation for an LLM) turn into “read the model” instead of “write another parallel definition and hope it stays in sync.”

When not to use it

Pydantic is close to free for anything crossing a trust boundary, but it’s not the right tool everywhere:

  • Pure internal data structures with no external input. If a function only ever receives data your own code already constructed and validated, a dataclass or plain class is lighter weight and avoids the (small but nonzero) validation overhead.
  • Extremely hot loops processing millions of already-trusted records. Pydantic v2 is fast, but it’s still doing validation work; if you’re processing a huge in-memory array of numbers you already know are clean, a plain tuple, NamedTuple, or NumPy array will outperform it.
  • One-off scripts where a dict is genuinely simpler. Not every 20-line script needs a modeling layer. Reach for Pydantic when the shape of your data matters enough that you want it enforced, not by default.

For anything else, an API boundary, a config file, a settings object, structured data passed between services, Pydantic turns “I hope the data looks like this” into “the data is guaranteed to look like this.”