🛡️ 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 comple...
