๐ก๏ธ Pydantic v2 Deep Dive & Custom Validators: Let's Learn from ZERO!
Almost every API you build needs to validate incoming data, reject invalid values, convert data into Python objects, and control what goes back to the frontend. In FastAPI, Pydantic does all of that. ๐
No Pydantic knowledge is assumed. We'll connect every concept to a React + FastAPI social-feed backend so you see how it works in a real project, not just as definitions.
1. ๐ค What problem does Pydantic solve?
Your React frontend sends this to your FastAPI backend:
{
"username": "deepika",
"age": 22,
"email": "deepika@gmail.com"
}
But what if it accidentally sends this?
{
"username": 12345,
"age": "twenty-two",
"email": "not-an-email"
}
- โ Username is a number instead of a string
- โ Age is text instead of a number
- โ Email is not a valid email address
Should your backend blindly accept it? Absolutely not! ๐
๐ Pydantic is a Python library for data validation, parsing, and serialization using Python type annotations. In simple words: it checks whether data follows the structure and rules you defined.
Think of Pydantic as a security checkpoint โ๏ธ at the entrance of your backend:
- โ Correct data โ allowed through
- โ Incorrect data โ validation error
- ๐ Data needing conversion โ converted when the rules permit
๐ค Why do we need it in FastAPI?
FastAPI integrates directly with Pydantic. When a request reaches an endpoint, FastAPI can use a Pydantic model to:
- Read incoming JSON
- Validate its fields
- Convert compatible values into Python types when allowed
- Give your endpoint a validated Python object
- Generate API documentation automatically
- Validate and serialize outgoing responses
2. ๐๏ธ What is a Pydantic model?
First, a normal Python class:
class User:
def __init__(self, username, age):
self.username = username
self.age = age
user = User("deepika", 22)
print(user.username) # deepika
print(user.age) # 22
But Python doesn't validate the types here:
user = User(12345, "twenty-two") # Python happily creates this ๐ฌ
Now with Pydantic:
pip install pydantic
from pydantic import BaseModel
class User(BaseModel):
username: str
age: int
user = User(username="deepika", age=22)
print(user)
username='deepika' age=22
-
BaseModelis the main Pydantic class that provides validation and serialization -
username: strandage: intare type annotations. They tell Pydantic what to expect
Now try User(username=12345, age="twenty-two") and Pydantic raises a ValidationError. ๐ฅ
๐ A Pydantic model is a Python class that inherits from
BaseModeland defines the expected structure and validation rules of data.
Incoming Python data / JSON
โผ
Pydantic BaseModel โ checks fields, types and validation rules
โโโ โ
Valid data โ Python model object
โโโ โ Invalid data โ ValidationError
3. ๐ Pydantic v2: What's New and Important?
Pydantic has two major versions: v1 (older API) and v2 (newer implementation, faster, with updated APIs). Focus on v2.
| Pydantic v1 | Pydantic v2 |
|---|---|
@validator |
@field_validator |
@root_validator |
@model_validator |
.dict() |
.model_dump() |
.json() |
.model_dump_json() |
.parse_obj() |
.model_validate() |
.schema() |
.model_json_schema() |
.copy() |
.model_copy() |
user.dict() # old
user.model_dump() # new โ
๐ก In new projects, learn and use the v2 APIs.
4. ๐๏ธ Required Fields, Optional Fields and Defaults
Not every field in an API request is mandatory. In a registration API, username, email and password are required, but a profile bio might be optional.
from pydantic import BaseModel
class UserCreate(BaseModel):
username: str
email: str
password: str
bio: str | None = None
-
username: strโ required -
bio: str | None = Noneโ can be a string, can beNone, and defaults toNoneif omitted
user = UserCreate(
username="deepika",
email="deepika@gmail.com",
password="secret123",
)
print(user.bio) # None
โ ๏ธ Optional vs default value (a classic interview question!)
class User(BaseModel):
bio: str | None # value may be None, but the field is STILL REQUIRED
class User(BaseModel):
bio: str | None = None # the user can omit it โ
In the first version, the caller must explicitly supply bio, even if the value is None.
๐งฎ Default values
class User(BaseModel):
username: str
is_active: bool = True
followers_count: int = 0
๐ญ Default factories
Need a new list for every object?
from pydantic import BaseModel, Field
class User(BaseModel):
username: str
interests: list[str] = Field(default_factory=list)
Field(default_factory=list) creates a fresh empty list whenever a default is needed, so objects never accidentally share the same mutable list. You can also use it for timestamps:
from datetime import datetime, timezone
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
Each object gets its own creation timestamp. โฑ๏ธ
5. ๐ Field(): Adding Validation Constraints
Imagine someone sends {"username": "", "age": -10}. Technically the username is a string and the age is an integer, but the values make no sense! We need extra rules. That's what Field() is for.
from pydantic import BaseModel, Field
class User(BaseModel):
username: str = Field(min_length=3, max_length=20)
age: int = Field(ge=18, le=100)
๐ค String constraints
| Constraint | Meaning |
|---|---|
min_length=3 |
Minimum 3 characters |
max_length=20 |
Maximum 20 characters |
pattern="..." |
Must match a regular expression |
username: str = Field(
min_length=3,
max_length=20,
pattern=r"^[a-zA-Z0-9_]+$",
)
This means 3 to 20 characters, using only letters, numbers and underscores.
๐ข Numeric constraints
| Constraint | Meaning |
|---|---|
gt=0 |
Greater than zero |
ge=18 |
Greater than or equal to 18 |
lt=100 |
Less than 100 |
le=100 |
Less than or equal to 100 |
multiple_of=5 |
Must be divisible by 5 |
price: float = Field(gt=0) # must be greater than zero
age: int = Field(ge=18, le=100) # between 18 and 100, inclusive
๐ Collection constraints
tags: list[str] = Field(min_length=1, max_length=5)
The list needs at least 1 item and at most 5. Because of list[str], Pydantic also checks that every item is a string.
๐ง Remember:
Field()defines declarative constraints. Validators define custom logic.min_length=3is a constraint, but "username must not contain a prohibited word" needs custom code.
6. ๐งช What are Custom Validators?
In your social-feed registration API you want to enforce:
- Username must not contain spaces
- Email must be lowercase
- Username must not contain offensive words
Field() can't express all of that. We need Python logic.
๐ A custom validator is a function you write to check whether a value (or a whole model) satisfies your application's rules.
Pydantic v2 gives you two decorators:
-
@field_validator: validates one field -
@model_validator: validates the whole model
7. ๐ฏ @field_validator: Validating Individual Fields
from pydantic import BaseModel, field_validator
class UserCreate(BaseModel):
username: str
@field_validator("username")
@classmethod
def validate_username(cls, value):
if " " in value:
raise ValueError("Username cannot contain spaces")
return value
Line by line:
-
@field_validator("username")โ "run this function when validating theusernamefield" -
@classmethodโ in Pydantic v2, field validators generally takeclsas the first parameter -
def validate_username(cls, value)โvalueis the value being validated (e.g."deepika") -
if " " in valueโ our check -
raise ValueError(...)โ rejects the value. Pydantic catches it and produces a structuredValidationError -
return valueโ allows the value to continue. A field validator must return the value it wants Pydantic to use
You can validate several fields with one validator:
@field_validator("username", "display_name")
@classmethod
def validate_names(cls, value):
return value.strip()
Testing it:
UserCreate(username="deepika") # โ
works
UserCreate(username="deepika rao") # โ ValidationError
๐ก
raise ValueError()rejects the input.return valuelets the validated value continue.
8. ๐ Field Validator Modes: Before, After, Plain, Wrap
@field_validator supports four modes:
| Mode | When does it run? |
|---|---|
before |
Before Pydantic's normal type validation |
after |
After normal type validation |
plain |
Replaces normal validation |
wrap |
Wraps normal validation and controls when it runs |
๐
ฐ๏ธ before: clean raw input
The frontend sends " deepika " and you want to strip the spaces first:
from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
@field_validator("username", mode="before")
@classmethod
def clean_username(cls, value):
if isinstance(value, str):
return value.strip()
return value
Incoming value: " deepika "
โผ
Before validator โ strip() removes extra spaces
โผ
Pydantic type validation โ checks the result is a string
โผ
Final value: "deepika"
Why isinstance(value, str)? Because before validation, Pydantic hasn't checked the input yet. It could be a string, integer, list or anything. A before validator must be ready for raw input.
Use before to clean, normalize or preprocess incoming data.
๐
ฑ๏ธ after: check validated values
class User(BaseModel):
age: int
@field_validator("age", mode="after")
@classmethod
def validate_age(cls, value):
if value < 18:
raise ValueError("User must be at least 18")
return value
For User(age="22"), in default (non-strict) mode Pydantic converts "22" to 22 first, and then your validator receives 22 and checks the business rule.
Use after for values that have already passed type validation. It's the easiest and safest mode for beginners. โ
๐
ฒ plain: take full control
A plain validator replaces Pydantic's normal validation:
from pydantic import BaseModel, field_validator
class Product(BaseModel):
quantity: int
@field_validator("quantity", mode="plain")
@classmethod
def validate_quantity(cls, value):
if isinstance(value, int) and value > 0:
return value
raise ValueError("Quantity must be a positive integer")
Even though the field says quantity: int, Pydantic won't run its normal integer validation after this. Your function is responsible for checking the input. Use plain only when you deliberately want to replace normal validation.
๐
ณ wrap: advanced control
A wrap validator receives the incoming value and a handler that runs Pydantic's normal validation. You can run logic before it, after it, or catch errors:
from pydantic import BaseModel, ValidatorFunctionWrapHandler, field_validator
class Product(BaseModel):
quantity: int
@field_validator("quantity", mode="wrap")
@classmethod
def validate_quantity(cls, value, handler: ValidatorFunctionWrapHandler):
print("Before normal validation")
result = handler(value)
print("After normal validation")
return result
For Product(quantity="10"):
Before normal validation
โผ
handler(value) โ Pydantic validates and converts "10" to 10
โผ
After normal validation
โผ
Return result
If the input is invalid, the handler raises a validation error, which you can catch to implement fallback behavior.
๐ All four modes at a glance
| Mode | Idea | Example |
|---|---|---|
before |
Preprocess first, then normal validation | Remove extra spaces |
after |
Normal validation first, then custom logic | Age must be at least 18 |
plain |
Custom validator replaces normal validation | Implement your own full validation |
wrap |
Custom logic controls normal validation | Intercept errors, fallback behavior |
๐ก Recommendation: become very comfortable with
beforeandafterfirst, then learnplainandwrap.
9. ๐งฉ @model_validator: Validating Multiple Fields Together
A registration form has password and confirm_password:
{
"password": "hello123",
"confirm_password": "hello456"
}
Individually both are valid strings. Together they violate a rule: they must match. A field validator can't conveniently solve this. Use @model_validator, which validates the entire model.
from pydantic import BaseModel, model_validator
class UserRegister(BaseModel):
username: str
password: str
confirm_password: str
@model_validator(mode="after")
def check_passwords(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
-
@field_validator("username")validates one specific field -
@model_validator(mode="after")validates the complete model - An
aftermodel validator receives the complete model instance, so you accessself.passwordandself.confirm_password -
return selfreturns the validated model instance
โช before model validators
A before model validator runs before field validation and receives the raw input:
from typing import Any
from pydantic import BaseModel, model_validator
class User(BaseModel):
username: str
email: str
@model_validator(mode="before")
@classmethod
def preprocess_data(cls, data: Any):
if isinstance(data, dict):
data = data.copy()
if "user_name" in data and "username" not in data:
data["username"] = data.pop("user_name")
return data
If someone sends {"user_name": "deepika", "email": "deepika@gmail.com"}, the validator turns it into {"username": "deepika", ...} and then normal field validation happens. Notice we copy the dictionary instead of modifying the original input.
๐ Model validator modes
| Mode | Behavior |
|---|---|
before |
Receives raw input before field validation |
after |
Receives the validated model instance |
wrap |
Can control and wrap the entire model validation process |
๐ Unlike field validators, model validators don't have a
plainmode.
โ๏ธ Field validator vs model validator
| Feature | Field validator | Model validator |
|---|---|---|
| Main purpose | Validate individual fields | Validate relationships between fields |
| Decorator | @field_validator |
@model_validator |
| Example | Username cannot contain spaces | Passwords must match |
| Can access whole model? | Not directly in the normal way | Yes, in after mode |
| Modes | before, after, plain, wrap | before, after, wrap |
10. โน๏ธ ValidationInfo: Accessing Other Field Values
Sometimes you validate one field based on another. For example, reject a display_name identical to the username:
from pydantic import BaseModel, ValidationInfo, field_validator
class User(BaseModel):
username: str
display_name: str
@field_validator("display_name")
@classmethod
def validate_display_name(cls, value, info: ValidationInfo):
username = info.data.get("username")
if username and value == username:
raise ValueError("Display name cannot equal username")
return value
info.data contains the previously validated fields.
โ ๏ธ Field validation normally follows the order in which fields are declared. Here
usernamecomes beforedisplay_name, so its validated value is available. Reverse the order andusernamemight not be available yet. For relationships between several fields,model_validator(mode="after")is often clearer.
11. ๐ Custom Validation: A Real-World Example
Registration rules for your social-feed app:
- Username has 3 to 20 characters
- Username cannot contain spaces
- Email must be lowercase
- Age must be at least 18
- Password has at least 8 characters
- Confirm password must match password
from pydantic import (
BaseModel,
Field,
field_validator,
model_validator,
)
class UserRegister(BaseModel):
username: str = Field(min_length=3, max_length=20)
email: str
age: int = Field(ge=18)
password: str = Field(min_length=8)
confirm_password: str
@field_validator("username")
@classmethod
def validate_username(cls, value):
if " " in value:
raise ValueError("Username cannot contain spaces")
return value.strip()
@field_validator("email")
@classmethod
def validate_email(cls, value):
value = value.strip().lower()
if "@" not in value:
raise ValueError("Invalid email")
return value
@model_validator(mode="after")
def validate_passwords(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
๐ This uses a simple email check to demonstrate custom logic. For production-grade email validation, use
EmailStrwith theemail-validatorpackage. And never store plain-text passwords. Hash them with an algorithm such as Argon2id.
๐งญ Where should validation happen?
Not every business rule belongs in Pydantic:
| Rule | Best place |
|---|---|
| Username must be a string | Pydantic |
| Username length must be 3 to 20 | Pydantic |
| Password confirmation must match | Pydantic |
| Email must have valid format | Pydantic |
| Username already exists in database | Service / database |
| User has permission to delete a post | Authorization / service layer |
| Post belongs to the logged-in user | Service / database |
Pydantic is for validating data structure and input rules. It shouldn't become responsible for database operations, authorization, or application workflows.
12. ๐ Request Schemas vs Response Schemas
๐จ EXTREMELY IMPORTANT for FastAPI projects!
A user registers and the frontend sends:
{
"username": "deepika",
"email": "deepika@gmail.com",
"password": "secret123"
}
Your database stores:
| id | username | password_hash | |
|---|---|---|---|
| 1 | deepika | deepika@gmail.com | $argon2id$... |
Should the backend send that complete record back to React? Never! You must never expose password hashes to the frontend. That's why we separate request and response schemas.
๐ ฐ๏ธ Request schema
What the client is allowed or expected to send:
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
email: str
password: str = Field(min_length=8)
๐ ฑ๏ธ Response schema
What the backend is allowed to return:
from pydantic import BaseModel
class UserResponse(BaseModel):
id: int
username: str
email: str
Notice there's no password field. That's intentional. ๐
๐ค Why separate them?
Frontend โ Backend: UserCreate (username, email, password)
โผ
Backend processes and stores data
โผ
Database: id, username, email, password_hash
โผ
Backend selects safe output fields
โผ
Backend โ Frontend: UserResponse (id, username, email) โ
no password hash
This gives you security, clear API contracts, better maintainability, independent validation for input and output, and control over exposed fields.
๐ Other common schema types
class UserCreate(BaseModel):
username: str
email: str
password: str
class UserUpdate(BaseModel):
username: str | None = None
bio: str | None = None
class UserResponse(BaseModel):
id: int
username: str
email: str
bio: str | None = None
class UserLogin(BaseModel):
email: str
password: str
| Schema | Responsibility |
|---|---|
UserCreate |
New user registration |
UserUpdate |
Updating profile details |
UserResponse |
Returning safe user information |
UserLogin |
Accepting login credentials |
๐ง A database model and a Pydantic schema are not the same thing. A SQLAlchemy model represents database structure and behavior. A Pydantic model represents the structure and validation of data entering or leaving your API.
13. โก FastAPI response_model: How Does It Work?
response_model tells FastAPI which schema should describe and validate the endpoint's output.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserCreate(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: int
username: str
@app.post("/users", response_model=UserResponse)
def create_user(user: UserCreate):
return {
"id": 1,
"username": user.username,
"password": user.password,
}
The function returns a dict with the password, but UserResponse only has id and username. FastAPI validates the returned data against UserResponse and serializes the response according to it. The extra password field is not included in the normal serialized response:
{
"id": 1,
"username": "deepika"
}
โ
Why is response_model useful?
- Validates outgoing response data
- Serializes data into the expected representation
- Filters fields that aren't part of the response schema
- Generates accurate API documentation
- Establishes a clear contract for the frontend
๐จ Security warning: don't rely on response filtering as your only security measure. Never pass secrets into response construction unnecessarily. Build safe response data explicitly and keep sensitive fields out of API-facing schemas.
๐ Request body vs response_model
@app.post("/users", response_model=UserResponse)
def create_user(user: UserCreate):
...
-
user: UserCreateโ defines the incoming request body -
response_model=UserResponseโ defines the outgoing response
๐๏ธ More response_model options
@app.get(
"/users/{user_id}",
response_model=UserResponse,
response_model_exclude_unset=True,
)
| Option | Meaning |
|---|---|
response_model_exclude_unset=True |
Excludes fields not explicitly set |
response_model_exclude_none=True |
Excludes fields whose value is None
|
response_model_exclude_defaults=True |
Excludes values equal to their defaults |
response_model_include={...} |
Includes selected fields |
response_model_exclude={...} |
Excludes selected fields |
๐ก Prefer creating a dedicated response schema over relying heavily on include/exclude tricks.
14. ๐ค Serialization: Python Objects โ JSON
user = UserResponse(
id=1,
username="deepika",
email="deepika@gmail.com",
)
This is a Python object, but React expects JSON. Converting it is serialization.
๐ Serialization means converting a Python object or structured data into a format that can be transmitted, stored, or consumed elsewhere.
๐
ฐ๏ธ model_dump(): model โ Python dict
user_data = user.model_dump()
print(user_data)
{"id": 1, "username": "deepika", "email": "deepika@gmail.com"}
It produces a Python dictionary, not a JSON string.
๐
ฑ๏ธ model_dump_json(): model โ JSON string
print(user.model_dump_json())
{"id":1,"username":"deepika","email":"deepika@gmail.com"}
๐
ฒ model_validate(): Python data โ validated model
user_data = {
"id": 1,
"username": "deepika",
"email": "deepika@gmail.com",
}
user = UserResponse.model_validate(user_data)
๐
ณ model_validate_json(): JSON text โ validated model
user_json = """
{
"id": 1,
"username": "deepika",
"email": "deepika@gmail.com"
}
"""
user = UserResponse.model_validate_json(user_json)
๐ Quick comparison
| Method | Purpose |
|---|---|
model_dump() |
Model โ Python dictionary |
model_dump_json() |
Model โ JSON string |
model_validate() |
Python data โ validated model |
model_validate_json() |
JSON string/bytes โ validated model |
model_copy() |
Create a copy of a model |
model_json_schema() |
Generate JSON Schema |
๐๏ธ Serialization options
user.model_dump(exclude={"email"})
# {"id": 1, "username": "deepika"}
user.model_dump(exclude_none=True) # skip fields whose value is None
user.model_dump(exclude_unset=True) # skip fields not explicitly supplied/set
โ ๏ธ
exclude_unsetandexclude_defaultsare different. A field explicitly provided with its default value is still considered set.
15. ๐ท๏ธ Aliasing: Different Names in Python and JSON
Your Python backend uses snake_case (created_at), but your React frontend uses camelCase (createdAt). Both mean the same thing. Do you rewrite your Python codebase in camelCase? No! Use aliases.
๐ An alias is an alternative name for a field, used when validating input or serializing output.
๐ ฐ๏ธ Basic alias
from pydantic import BaseModel, Field
class Post(BaseModel):
created_at: str = Field(alias="createdAt")
post = Post(createdAt="2026-11-11")
print(post.created_at) # 2026-11-11
Python still uses created_at, but incoming data used createdAt.
๐
ฑ๏ธ alias vs validation_alias vs serialization_alias
| Property | Purpose |
|---|---|
alias |
Alias for both validation and serialization |
validation_alias |
Alternative name when accepting input |
serialization_alias |
Alternative name when producing output |
validation_alias: the frontend sends {"userName": "deepika"} but your field is username:
class User(BaseModel):
username: str = Field(validation_alias="userName")
serialization_alias: you want created_at to appear as createdAt in outgoing JSON:
class Post(BaseModel):
created_at: str = Field(serialization_alias="createdAt")
post = Post(created_at="2026-11-11")
print(post.model_dump(by_alias=True))
# {"createdAt": "2026-11-11"}
print(post.model_dump())
# {"created_at": "2026-11-11"}
by_alias=True tells Pydantic to use the serialization alias instead of the Python field name.
๐ ฒ Different input and output names
class User(BaseModel):
username: str = Field(
validation_alias="userName",
serialization_alias="displayName",
)
user = User(userName="deepika")
print(user.model_dump(by_alias=True))
# {"displayName": "deepika"}
Incoming JSON โ userName
Python model โ username
Outgoing JSON โ displayName
๐ ณ Alias generators
With 50 fields (created_at, updated_at, first_name, ...) you don't want to write each alias by hand:
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class User(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
validate_by_alias=True,
validate_by_name=True,
)
first_name: str
last_name: str
created_at: str
| Python field | Alias |
|---|---|
first_name |
firstName |
last_name |
lastName |
created_at |
createdAt |
user.model_dump(by_alias=True)
# {"firstName": "Deepika", "lastName": "Rao", "createdAt": "2026-11-11"}
๐
validate_by_nameandvalidate_by_aliasare the granular settings in newer Pydantic v2 releases. Older v2 examples commonly usepopulate_by_name=True.
16. โ๏ธ ConfigDict: Configuring Pydantic Models
Sometimes you want rules for the entire model: should extra fields be allowed? Should strings be auto-stripped? Should strict validation be on? Can the model be built from object attributes? Pydantic v2 uses ConfigDict:
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
)
username: str
email: str
๐ซ Understanding extra
The frontend sends:
{
"username": "deepika",
"email": "deepika@gmail.com",
"admin": true
}
But the model defines only username and email. What should happen?
| Setting | Behavior |
|---|---|
extra="ignore" |
Ignore extra fields (default) |
extra="forbid" |
Reject extra fields |
extra="allow" |
Allow extra fields and retain them |
For registration APIs, extra="forbid" is useful when you want unexpected input rejected rather than silently ignored.
๐ง Other useful settings
model_config = ConfigDict(strict=True)
Strict mode prevents many automatic type conversions. For example, an int field generally won't accept "22" in strict Python-object validation.
model_config = ConfigDict(from_attributes=True)
This lets Pydantic read data from object attributes, which is handy with ORM objects (more on this in section 21).
17. ๐ช Nested Pydantic Models
Real API responses are rarely flat. A post contains an ID, caption, author info and comments. Instead of one giant model, create smaller models and nest them:
from pydantic import BaseModel
class UserResponse(BaseModel):
id: int
username: str
class CommentResponse(BaseModel):
id: int
content: str
author: UserResponse
class PostResponse(BaseModel):
id: int
caption: str
author: UserResponse
comments: list[CommentResponse]
Example response:
{
"id": 101,
"caption": "My first post!",
"author": { "id": 1, "username": "deepika" },
"comments": [
{
"id": 10,
"content": "Beautiful!",
"author": { "id": 2, "username": "sneha" }
}
]
}
Pydantic validates the post, the author inside the post, every comment in the list, and the author inside each comment. This is nested model validation. ๐ฏ
18. ๐จ Enums and Literal Values
A post's visibility should only be public, private or followers. You don't want the frontend sending "secret_everyone". Use an Enum:
from enum import Enum
from pydantic import BaseModel
class Visibility(str, Enum):
PUBLIC = "public"
PRIVATE = "private"
FOLLOWERS = "followers"
class PostCreate(BaseModel):
caption: str
visibility: Visibility
PostCreate(caption="Hello", visibility="public") # โ
PostCreate(caption="Hello", visibility="everyone") # โ rejected
For a small fixed set of strings you can use Literal:
from typing import Literal
class PostCreate(BaseModel):
visibility: Literal["public", "private", "followers"]
19. ๐จ Pydantic Validation Errors
from pydantic import BaseModel, ValidationError
class User(BaseModel):
username: str
age: int
try:
user = User(username="deepika", age="hello")
except ValidationError as error:
print(error)
error.errors() returns structured information:
-
type: type of validation failure -
loc: location of the invalid field -
msg: human-readable message -
input: the invalid input - sometimes
ctx: additional error context
โก What happens in FastAPI?
@app.post("/users")
def create_user(user: UserCreate):
return user
If the frontend sends invalid JSON data, FastAPI normally returns HTTP 422 Unprocessable Entity:
{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "age"],
"msg": "Input should be greater than or equal to 18"
}
]
}
The exact structure depends on the failure. The important distinction:
- โ Request validation failure โ normally HTTP 422
- ๐ฅ Invalid data returned by your endpoint that violates
response_modelโ server-side response validation failure, normally HTTP 500
Why 500? Because the server failed to produce the response promised by its own API contract.
20. ๐ฆ EmailStr, HttpUrl and Other Built-in Types
from pydantic import BaseModel, EmailStr, HttpUrl
class User(BaseModel):
username: str
email: EmailStr
profile_url: HttpUrl
To use EmailStr, install the email validation dependency:
pip install "pydantic[email]"
| Type | Purpose |
|---|---|
EmailStr |
Email validation |
HttpUrl |
HTTP/HTTPS URL validation |
AnyUrl |
General URL validation |
IPvAnyAddress |
IPv4 or IPv6 address validation |
PositiveInt |
Integer greater than zero |
NonNegativeInt |
Integer greater than or equal to zero |
PositiveFloat |
Positive floating-point number |
from pydantic import BaseModel, PositiveInt
class Post(BaseModel):
id: PositiveInt
likes_count: int = 0
๐ก You don't need a custom validator for every simple rule. Use Pydantic's built-in types whenever they fit.
21. ๐ from_attributes: Connecting Pydantic with SQLAlchemy
Especially important for a FastAPI + PostgreSQL project. Your SQLAlchemy model is a Python object with attributes (id, username, email), but a Pydantic model normally expects data. How can Pydantic read the SQLAlchemy object's attributes?
from pydantic import BaseModel, ConfigDict
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str
response = UserResponse.model_validate(user_object)
Pydantic reads user_object.id, user_object.username, user_object.email and creates a validated Pydantic object.
๐ PostgreSQL โ stored user record
โผ
๐๏ธ SQLAlchemy ORM โ Python database object
โผ
โ๏ธ Pydantic model_validate() (from_attributes=True)
โผ
๐ค UserResponse โ validated response object
โผ
JSON response โ โ๏ธ React frontend
๐
from_attributes=Truedoes not execute database queries. SQLAlchemy retrieves the object, and Pydantic reads its attributes.
22. ๐๏ธ Pydantic Schemas in a Real FastAPI Folder Structure
backend/
โ
โโโ app/
โ โโโ api/
โ โ โโโ routes/
โ โ โโโ users.py
โ โ
โ โโโ schemas/
โ โ โโโ __init__.py
โ โ โโโ user.py
โ โ
โ โโโ models/
โ โ โโโ user.py
โ โ
โ โโโ services/
โ โ โโโ user_service.py
โ โ
โ โโโ main.py
โ
โโโ tests/
โโโ test_user_schemas.py
| Folder / file | Responsibility |
|---|---|
schemas/ |
Pydantic request and response models |
models/ |
SQLAlchemy database models |
api/routes/ |
FastAPI endpoint definitions |
services/ |
Application business logic |
tests/ |
Tests for schemas and functionality |
schemas/user.py
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
email: EmailStr
password: str = Field(min_length=8)
class UserResponse(BaseModel):
id: int
username: str
email: EmailStr
api/routes/users.py
from fastapi import APIRouter
from app.schemas.user import UserCreate, UserResponse
router = APIRouter()
@router.post("/users", response_model=UserResponse)
def create_user(user: UserCreate):
# Call service layer here
return {
"id": 1,
"username": user.username,
"email": user.email,
}
Your route doesn't contain every validation rule. Pydantic schemas handle data validation, while services handle business logic.
๐ What happens when the frontend calls this API?
- React sends JSON
- FastAPI receives the request
- FastAPI validates the body using
UserCreate - If validation fails, FastAPI returns a 422 error
- If it succeeds, the endpoint receives a
UserCreateobject - The endpoint calls the service layer
- The service performs application logic and database operations
- The endpoint returns data
- FastAPI validates and serializes it using
UserResponse - React receives JSON
23. ๐งช Testing Pydantic Validators with pytest
Knowing how to write validators is good. Knowing how to test them is better.
from pydantic import BaseModel, Field, field_validator
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
@field_validator("username")
@classmethod
def validate_username(cls, value):
if " " in value:
raise ValueError("Spaces are not allowed")
return value
tests/test_user_schema.py:
import pytest
from pydantic import ValidationError
def test_valid_username():
user = UserCreate(username="deepika")
assert user.username == "deepika"
def test_username_with_spaces():
with pytest.raises(ValidationError):
UserCreate(username="deepika rao")
def test_username_too_short():
with pytest.raises(ValidationError):
UserCreate(username="ab")
pytest.raises(ValidationError) means "we expect this code to raise a validation error." If it does, the test passes. If the code unexpectedly accepts invalid data, the test fails.
What to test:
- โ Valid input
- โ Missing required fields
- โ Incorrect types
- ๐ Minimum and maximum constraints
- ๐งช Custom validator failures
- ๐งฉ Cross-field validation
- ๐ท๏ธ Serialization and aliases
24. ๐ Important Advanced Concepts
๐ ฐ๏ธ Strict validation
By default, Pydantic tries to convert compatible values:
class User(BaseModel):
age: int
User(age="22") # "22" is converted into 22
To reject conversions:
class User(BaseModel):
age: int = Field(strict=True) # "22" is now rejected
Or for the whole model: model_config = ConfigDict(strict=True).
๐ ฑ๏ธ Computed fields
Include a value calculated from other fields:
from pydantic import BaseModel, computed_field
class User(BaseModel):
first_name: str
last_name: str
@computed_field
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
user = User(first_name="Deepika", last_name="Rao")
print(user.full_name) # Deepika Rao
user.model_dump() includes full_name in the serialized output.
๐ ฒ JSON Schema
print(User.model_json_schema())
JSON Schema describes field names, types, required fields, constraints and descriptions. FastAPI uses this to generate the OpenAPI docs that power Swagger UI and ReDoc.
๐ ณ Custom error types
from pydantic_core import PydanticCustomError
raise PydanticCustomError(
"username_taken",
"Username '{username}' is already taken",
{"username": "deepika"},
)
Useful when you want a specific, machine-readable validation error type.
๐ ด Validation context
User.model_validate(
{"username": "deepika"},
context={"allowed_usernames": ["deepika", "sneha"]},
)
A validator can read this through info.context, which helps with dynamic rules. But rules requiring database queries should usually stay in your service layer.
25. ๐ Complete Pydantic v2 Cheat Sheet
| Concept | What it does | Example |
|---|---|---|
BaseModel |
Creates Pydantic models | class User(BaseModel) |
Field() |
Defines field constraints | Field(min_length=3) |
field_validator |
Validates individual fields | @field_validator("username") |
model_validator |
Validates multiple fields together | @model_validator(mode="after") |
ValidationInfo |
Accesses validation information | info.data |
ConfigDict |
Configures model behavior | extra="forbid" |
EmailStr |
Validates email addresses | email: EmailStr |
from_attributes |
Reads attributes from objects | from_attributes=True |
validation_alias |
Changes accepted input field name | Field(validation_alias="userName") |
serialization_alias |
Changes serialized field name | Field(serialization_alias="createdAt") |
model_dump() |
Converts model to dictionary | user.model_dump() |
model_dump_json() |
Converts model to JSON string | user.model_dump_json() |
model_validate() |
Validates Python data | User.model_validate(data) |
model_validate_json() |
Validates JSON data | User.model_validate_json(data) |
model_copy() |
Copies a model | user.model_copy() |
computed_field |
Adds calculated serialized fields | @computed_field |
26. ๐ค Common Interview Questions
1. What is Pydantic?
A Python library for data validation, parsing and serialization based on type annotations.
2. What's the difference between BaseModel and a normal Python class?
A normal class stores whatever you pass without checking types. A BaseModel validates (and where allowed, converts) data against its annotations and constraints, and provides serialization methods.
3. What's the difference between field_validator and model_validator?
field_validator validates individual fields. model_validator validates the whole model, so it can check relationships between fields, such as password and confirm password.
4. What's the difference between before and after validators?
before runs before Pydantic's type validation and receives raw input, which suits cleaning and normalizing. after runs after type validation and receives the already validated value, which suits business rules.
5. Why do we separate request and response schemas?
Request schemas validate what clients send. Response schemas control what the API returns, preventing sensitive fields such as password hashes from being exposed, and giving the frontend a clear contract.
6. What does response_model do in FastAPI?
It tells FastAPI which schema describes and validates the output. FastAPI validates and serializes the returned data against it, filters fields not in the schema, and documents the response in OpenAPI.
7. What is serialization?
Converting a Python object or model into a format that can be transmitted or stored, such as a dictionary (model_dump()) or a JSON string (model_dump_json()).
8. What's the difference between alias and serialization_alias?
alias applies to both validation (input) and serialization (output). serialization_alias only changes the field name in outgoing data, used with by_alias=True.
9. What is from_attributes=True?
A ConfigDict setting that lets Pydantic read values from object attributes, such as SQLAlchemy ORM objects, when using model_validate().
10. What's the difference between ValidationError and HTTP 422?
ValidationError is the Pydantic exception raised when validation fails. HTTP 422 is the response FastAPI sends to the client when request validation fails. A response-validation failure in your own endpoint is usually a server error (500).
27. ๐๏ธ Practical Exercises
Try these in a separate Python file:
- Create a
Productmodel with name, price and quantity. Price must be greater than zero - Create a username
field_validatorthat removes extra spaces - Create an email validator that converts emails to lowercase
- Create a registration model with
passwordandconfirm_password. Reject mismatches - Create
UserCreateandUserResponseschemas. Never expose the password in the response - Create a
Postmodel with a nestedUserResponseauthor and a list of comments - Use
validation_aliasandserialization_aliasto convertusernameintouserNameon input anddisplayNameon output - Create a FastAPI POST endpoint using a request schema and
response_model - Enable
extra="forbid"and test what happens when an unknown field is submitted - Write pytest tests for valid and invalid usernames
28. ๐ What to Remember
- ๐๏ธ Pydantic models define the structure of API data
- ๐
Fieldconstraints enforce basic rules such as minimum lengths and positive numbers - ๐งช Custom validators enforce additional input rules
- ๐ฅ Request schemas define what the frontend can send
- ๐ค Response schemas define what the backend returns
- ๐ Serialization converts Python model data into dictionaries or JSON
- ๐ท๏ธ Aliases let Python and JavaScript follow different naming conventions
- โก FastAPI integrates all of this into the request-response lifecycle
- ๐๏ธ SQLAlchemy handles database persistence; Pydantic handles API data validation and serialization
- ๐ง Services handle business logic that shouldn't live inside your schemas
๐ฏ One important architectural lesson: Pydantic validates whether data is structurally and semantically acceptable. It does not replace database constraints, authentication, authorization, or business logic.
๐ What's Next?
Implement these concepts in a mini registration and post-creation API: write the schemas, add validators, wire up response_model, and cover it all with pytest. ๐ก
If this post helped you, drop a โค๏ธ or ๐ฆ and share it with a friend learning FastAPI. Happy coding! ๐ฉโ๐ปโจ
Top comments (0)