Search Topics

Search across all FastAPI topics

GitHub

Pydantic Models

Core Concept

Pydantic is the data validation library that powers FastAPI. Models define the shape of your data with Python type annotations, giving you validation, serialization, and documentation for free.

You add email: str to your User model. Someone signs up with email: "not-an-email". It passes validation. Your database now has garbage data. Why didn't Pydantic catch this?

terminal
POST /users {"name": "Bob", "email": "not-an-email"}
→ 200 OK  ✓ Created!

# Wait... "not-an-email" is not a valid email!
# But str accepts ANY string. Pydantic did exactly what you asked.

Question

Frustrating, right? You expected Pydantic to validate emails, but you told it the type was str. And "not-an-email" is a valid string. Pydantic did exactly what you asked — the problem is you didn't ask for enough. This is where custom validators and specialized types come in.

The Validation Pipeline

When data hits a Pydantic model, every field goes through a validation pipeline. The type hint is just the first check. You can add more layers with Field(), validators, and specialized types.

Raw data arrives

{"email": "not-an-email"}

Type check

Is it a str? Yes

Field constraints

min_length? pattern?

Custom validators

@field_validator?

Model accepted

All checks passed

If you only declare email: str, only the type check runs. The more constraints you add, the more Pydantic validates for you.

Validation pipeline

What you just learned

Type hints are just the first layer of validation

Field() adds constraints like min_length, regex patterns, and more

Custom validators run after type checking for business-logic validation

Defining Models

Create models by subclassing BaseModel. Each field uses standard Python type annotations. Add Field() when you need constraints beyond the basic type.

schemas.py
from pydantic import BaseModel, Field
from datetime import datetime

class User(BaseModel):
    id: int
    name: str = Field(min_length=1, max_length=100)
    email: str
    is_active: bool = True
    created_at: datetime = Field(default_factory=datetime.now)
    tags: list[str] = []

Default values make fields optional. is_active defaults to True, tags defaults to an empty list. Fields without defaults (id, name, email) are required.

Nested Models

Real-world data is rarely flat. Models can reference other models, and Pydantic validates at every level. If the nested address has a bad zip code, you'll know exactly where.

schemas.py
class Address(BaseModel):
    street: str
    city: str
    country: str
    zip_code: str

class User(BaseModel):
    name: str
    address: Address                     # nested model
    shipping_addresses: list[Address] = []  # list of models

Model structure

What you just learned

BaseModel subclasses define your data shape

Field() adds min/max length, patterns, and metadata

Nested models validate at every level with precise error paths

Fixing the Email Problem

Your User model has email: str and someone signs up with "not-an-email". How do you actually validate emails?

Broken code
schemas.py
class User(BaseModel):
    name: str
    email: str  # Accepts ANY string!

# POST {"name": "Bob", "email": "not-an-email"}
# → 200 OK ← This shouldn't pass!

Custom Field Validators

The @field_validator decorator is your tool for business logic that goes beyond type checking. Want to ensure ages are reasonable? That passwords meet complexity requirements? Validators are the answer.

schemas.py
from pydantic import BaseModel, field_validator

class User(BaseModel):
    name: str
    email: str
    age: int

    @field_validator("email")
    @classmethod
    def validate_email(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("Invalid email address")
        return v.lower()

    @field_validator("age")
    @classmethod
    def validate_age(cls, v: int) -> int:
        if v < 0 or v > 150:
            raise ValueError("Age must be between 0 and 150")
        return v

Insight

Validators can do two things: reject bad data (raise ValueError) and transform good data (return a modified value). The email validator does both — it rejects strings without @ and lowercases valid emails.

Custom validation

What you just learned

@field_validator adds business logic validation beyond type checking

Validators can reject (raise ValueError) and transform (return modified value)

Validators run after type coercion, so you get the correct type

Try It Yourself

Edit the JSON below and hit Validate to see how Pydantic checks each field. Switch between models or click "break it" to see validation errors in action.

Think about it...

What's the difference between a Pydantic model and a Python dataclass for API validation?

Hint: Think about what happens when you pass the string '25' to a field declared as int.

Key Points

Type-Driven

Python type hints define validation, serialization, and docs

Nested Validation

Deeply nested structures are validated at every level

Custom Validators

Add business logic with @field_validator and @model_validator

model_dump()

Convert models to dicts with .model_dump() for serialization