Pydantic Models and Validators
Scenario
Your API accepts a 'discount_percentage' field. A user sends 'discount_percentage': 150. Your database accepts it, and suddenly every order is completely free.
How do you guarantee that bad data never even reaches your business logic?
Mental model
Pydantic is a ruthless bouncer at the club door.
It checks everyone's ID (data types) and applies the club rules (validators). If the payload says age=17, the bouncer rejects them with a 422 Unprocessable Entity before they even step inside your FastAPI route. Your internal code never has to worry about checking types or rules again.
Deep dive
In a backend, you can't trust the data the client sends. Instead of scattering `if age < 0` checks throughout your handlers, you declare a Pydantic model. You state exactly what the data should look like using standard Python type hints. Pydantic enforces this boundary.
Models define the boundary: bad data bounces before your logic runs.
Pydantic does two things: parsing and validation. Parsing means if you expect an integer and receive the string `"42"`, Pydantic silently converts it to the integer `42` (coercion). Validation means if you expect an integer and receive `"apple"`, Pydantic raises a clear validation error detailing exactly which field failed.
Parsing coerces types when possible; validation rejects what can't be coerced.
Standard types aren't always enough. A string could be empty, or an integer might be out of range. Pydantic v2 uses `Field` constraints (`min_length`, `ge` for greater-than-or-equal) and the `@field_validator` decorator to enforce custom business rules directly on the data shape.
Use Field for simple bounds and @field_validator for custom logic.
When a Pydantic v2 model fails validation inside FastAPI, the framework automatically catches the `ValidationError` and returns a 422 HTTP response to the client. The response body is a highly detailed JSON array explaining every single field that failed and why. You never write this error handling code yourself.
FastAPI + Pydantic automatically turns validation failures into 422 responses.
Code examples
The naive way (no Pydantic)
def create_user(payload: dict):
# Business logic polluted with validation
if "age" not in payload:
raise ValueError("age is required")
if not isinstance(payload["age"], int):
raise ValueError("age must be int")
if payload["age"] < 18:
raise ValueError("must be 18 or older")
return save_to_db(payload)
Without a model, you have to manually check for missing keys, wrong types, and business rules. It scales poorly and makes the handler unreadable.
The realistic version (Pydantic v2)
from pydantic import BaseModel, Field, field_validator
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
age: int = Field(ge=18) # greater than or equal to 18
email: str
@field_validator('email')
@classmethod
def must_be_company_email(cls, v: str) -> str:
if not v.endswith('@company.com'):
raise ValueError('Must use a company email address')
return v
Here, Pydantic v2's `Field` handles simple constraints, while `@field_validator` handles custom logic. The classmethod takes the raw value and either returns it or raises an error.
The modern v2 configuration pattern
from pydantic import BaseModel, ConfigDict
class UserResponse(BaseModel):
# v2 configuration replaces the old `class Config:`
model_config = ConfigDict(from_attributes=True, extra='forbid')
id: int
username: str
email: str
In Pydantic v2, configurations use `model_config = ConfigDict(...)`. The `from_attributes=True` setting is critical for serializers, allowing Pydantic to read fields from SQLAlchemy ORM objects (e.g. `user.id`), not just dictionaries.
Key points
- Type Coercion: Pydantic tries to convert data to the declared type (e.g., '1' to 1) before failing.
- Field vs field_validator: Use `Field(ge=0)` for standard constraints. Use `@field_validator` for custom Python logic.
- ConfigDict over class Config: Pydantic v2 configures models using the `model_config` attribute and `ConfigDict`, not the v1 inner class.
Common mistakes
- Using Pydantic v1 idioms in modern codebases: Using `@validator` or `class Config: orm_mode = True` is instantly flagged as outdated in 2026 interviews. Pydantic v2 requires `@field_validator` and `model_config = ConfigDict(from_attributes=True)`.
- Forgetting @classmethod on validators: In Pydantic v2, `@field_validator` methods must be class methods (they take `cls` and the value), because validation runs before the instance is fully constructed.
Recall questions
- What happens if a client sends the string "42" for a Pydantic field typed as `int`?
- How do you configure a Pydantic v2 model to read data from ORM objects (like SQLAlchemy) instead of just dictionaries?
- Why must a `@field_validator` method use `@classmethod`?
Questions & answers
A candidate's code review submission includes: `class User(BaseModel): class Config: orm_mode = True`. What is wrong with this in a modern FastAPI application?
It uses Pydantic v1 syntax. In modern FastAPI (Pydantic v2), configurations must use `model_config = ConfigDict(from_attributes=True)`.
Approach: Identify the outdated `class Config` and `orm_mode` syntax, and provide the correct v2 `ConfigDict` equivalent.
You have a Pydantic model for user registration. How do you enforce that the 'password' and 'confirm_password' fields match?
Use a `@model_validator(mode='after')` (or `mode='before'`) to inspect the entire model's data. A single `@field_validator` only has access to its own field, not the others.
Approach: Explain that field validators validate single fields in isolation, whereas a model validator has access to the full payload.
Continue learning
Previous: Request Body & response_model