DEV Community

Deepika Pusala
Deepika Pusala

Posted on

Pydantic v2 Deep Dive & Custom Validators: Explained from Absolute Zero ๐Ÿ›ก๏ธ

๐Ÿ›ก๏ธ 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"
}
Enter fullscreen mode Exit fullscreen mode

But what if it accidentally sends this?

{
  "username": 12345,
  "age": "twenty-two",
  "email": "not-an-email"
}
Enter fullscreen mode Exit fullscreen mode
  • โŒ 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:

  1. Read incoming JSON
  2. Validate its fields
  3. Convert compatible values into Python types when allowed
  4. Give your endpoint a validated Python object
  5. Generate API documentation automatically
  6. 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
Enter fullscreen mode Exit fullscreen mode

But Python doesn't validate the types here:

user = User(12345, "twenty-two")  # Python happily creates this ๐Ÿ˜ฌ
Enter fullscreen mode Exit fullscreen mode

Now with Pydantic:

pip install pydantic
Enter fullscreen mode Exit fullscreen mode
from pydantic import BaseModel


class User(BaseModel):
    username: str
    age: int


user = User(username="deepika", age=22)

print(user)
Enter fullscreen mode Exit fullscreen mode
username='deepika' age=22
Enter fullscreen mode Exit fullscreen mode
  • BaseModel is the main Pydantic class that provides validation and serialization
  • username: str and age: int are 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 BaseModel and 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
Enter fullscreen mode Exit fullscreen mode

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 โœ…
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก 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
Enter fullscreen mode Exit fullscreen mode
  • username: str โ†’ required
  • bio: str | None = None โ†’ can be a string, can be None, and defaults to None if omitted
user = UserCreate(
    username="deepika",
    email="deepika@gmail.com",
    password="secret123",
)

print(user.bio)  # None
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Optional vs default value (a classic interview question!)

class User(BaseModel):
    bio: str | None        # value may be None, but the field is STILL REQUIRED
Enter fullscreen mode Exit fullscreen mode
class User(BaseModel):
    bio: str | None = None  # the user can omit it โœ…
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿญ 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)
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”ค 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_]+$",
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“š Collection constraints

tags: list[str] = Field(min_length=1, max_length=5)
Enter fullscreen mode Exit fullscreen mode

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=3 is 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:

  1. Username must not contain spaces
  2. Email must be lowercase
  3. 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
Enter fullscreen mode Exit fullscreen mode

Line by line:

  • @field_validator("username") โ†’ "run this function when validating the username field"
  • @classmethod โ†’ in Pydantic v2, field validators generally take cls as the first parameter
  • def validate_username(cls, value) โ†’ value is the value being validated (e.g. "deepika")
  • if " " in value โ†’ our check
  • raise ValueError(...) โ†’ rejects the value. Pydantic catches it and produces a structured ValidationError
  • 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()
Enter fullscreen mode Exit fullscreen mode

Testing it:

UserCreate(username="deepika")          # โœ… works
UserCreate(username="deepika rao")      # โŒ ValidationError
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก raise ValueError() rejects the input. return value lets 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
Enter fullscreen mode Exit fullscreen mode
Incoming value: "   deepika   "
        โ–ผ
Before validator โ†’ strip() removes extra spaces
        โ–ผ
Pydantic type validation โ†’ checks the result is a string
        โ–ผ
Final value: "deepika"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For Product(quantity="10"):

Before normal validation
        โ–ผ
handler(value) โ†’ Pydantic validates and converts "10" to 10
        โ–ผ
After normal validation
        โ–ผ
Return result
Enter fullscreen mode Exit fullscreen mode

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 before and after first, then learn plain and wrap.


9. ๐Ÿงฉ @model_validator: Validating Multiple Fields Together

A registration form has password and confirm_password:

{
  "password": "hello123",
  "confirm_password": "hello456"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • @field_validator("username") validates one specific field
  • @model_validator(mode="after") validates the complete model
  • An after model validator receives the complete model instance, so you access self.password and self.confirm_password
  • return self returns 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
Enter fullscreen mode Exit fullscreen mode

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 plain mode.

โš”๏ธ 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
Enter fullscreen mode Exit fullscreen mode

info.data contains the previously validated fields.

โš ๏ธ Field validation normally follows the order in which fields are declared. Here username comes before display_name, so its validated value is available. Reverse the order and username might 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:

  1. Username has 3 to 20 characters
  2. Username cannot contain spaces
  3. Email must be lowercase
  4. Age must be at least 18
  5. Password has at least 8 characters
  6. 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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ This uses a simple email check to demonstrate custom logic. For production-grade email validation, use EmailStr with the email-validator package. 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"
}
Enter fullscreen mode Exit fullscreen mode

Your database stores:

id username email 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)
Enter fullscreen mode Exit fullscreen mode

๐Ÿ…ฑ๏ธ Response schema

What the backend is allowed to return:

from pydantic import BaseModel


class UserResponse(BaseModel):
    id: int
    username: str
    email: str
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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,
    }
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

โœ… Why is response_model useful?

  1. Validates outgoing response data
  2. Serializes data into the expected representation
  3. Filters fields that aren't part of the response schema
  4. Generates accurate API documentation
  5. 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):
    ...
Enter fullscreen mode Exit fullscreen mode
  • 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,
)
Enter fullscreen mode Exit fullscreen mode
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",
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
{"id": 1, "username": "deepika", "email": "deepika@gmail.com"}
Enter fullscreen mode Exit fullscreen mode

It produces a Python dictionary, not a JSON string.

๐Ÿ…ฑ๏ธ model_dump_json(): model โ†’ JSON string

print(user.model_dump_json())
Enter fullscreen mode Exit fullscreen mode
{"id":1,"username":"deepika","email":"deepika@gmail.com"}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ…ฒ model_validate(): Python data โ†’ validated model

user_data = {
    "id": 1,
    "username": "deepika",
    "email": "deepika@gmail.com",
}

user = UserResponse.model_validate(user_data)
Enter fullscreen mode Exit fullscreen mode

๐Ÿ…ณ model_validate_json(): JSON text โ†’ validated model

user_json = """
{
    "id": 1,
    "username": "deepika",
    "email": "deepika@gmail.com"
}
"""

user = UserResponse.model_validate_json(user_json)
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“Š 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
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ exclude_unset and exclude_defaults are 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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode
Incoming JSON  โ†’  userName
Python model   โ†’  username
Outgoing JSON  โ†’  displayName
Enter fullscreen mode Exit fullscreen mode

๐Ÿ…ณ 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
Enter fullscreen mode Exit fullscreen mode
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"}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ validate_by_name and validate_by_alias are the granular settings in newer Pydantic v2 releases. Older v2 examples commonly use populate_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
Enter fullscreen mode Exit fullscreen mode

๐Ÿšซ Understanding extra

The frontend sends:

{
  "username": "deepika",
  "email": "deepika@gmail.com",
  "admin": true
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

Example response:

{
  "id": 101,
  "caption": "My first post!",
  "author": { "id": 1, "username": "deepika" },
  "comments": [
    {
      "id": 10,
      "content": "Beautiful!",
      "author": { "id": 2, "username": "sneha" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
PostCreate(caption="Hello", visibility="public")     # โœ…
PostCreate(caption="Hello", visibility="everyone")   # โŒ rejected
Enter fullscreen mode Exit fullscreen mode

For a small fixed set of strings you can use Literal:

from typing import Literal


class PostCreate(BaseModel):
    visibility: Literal["public", "private", "followers"]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To use EmailStr, install the email validation dependency:

pip install "pydantic[email]"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก 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
Enter fullscreen mode Exit fullscreen mode
response = UserResponse.model_validate(user_object)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“Œ from_attributes=True does 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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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,
    }
Enter fullscreen mode Exit fullscreen mode

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?

  1. React sends JSON
  2. FastAPI receives the request
  3. FastAPI validates the body using UserCreate
  4. If validation fails, FastAPI returns a 422 error
  5. If it succeeds, the endpoint receives a UserCreate object
  6. The endpoint calls the service layer
  7. The service performs application logic and database operations
  8. The endpoint returns data
  9. FastAPI validates and serializes it using UserResponse
  10. 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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To reject conversions:

class User(BaseModel):
    age: int = Field(strict=True)   # "22" is now rejected
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

user.model_dump() includes full_name in the serialized output.

๐Ÿ…ฒ JSON Schema

print(User.model_json_schema())
Enter fullscreen mode Exit fullscreen mode

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"},
)
Enter fullscreen mode Exit fullscreen mode

Useful when you want a specific, machine-readable validation error type.

๐Ÿ…ด Validation context

User.model_validate(
    {"username": "deepika"},
    context={"allowed_usernames": ["deepika", "sneha"]},
)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a Product model with name, price and quantity. Price must be greater than zero
  2. Create a username field_validator that removes extra spaces
  3. Create an email validator that converts emails to lowercase
  4. Create a registration model with password and confirm_password. Reject mismatches
  5. Create UserCreate and UserResponse schemas. Never expose the password in the response
  6. Create a Post model with a nested UserResponse author and a list of comments
  7. Use validation_alias and serialization_alias to convert username into userName on input and displayName on output
  8. Create a FastAPI POST endpoint using a request schema and response_model
  9. Enable extra="forbid" and test what happens when an unknown field is submitted
  10. Write pytest tests for valid and invalid usernames

28. ๐Ÿ What to Remember

  1. ๐Ÿ—๏ธ Pydantic models define the structure of API data
  2. ๐Ÿ“ Field constraints enforce basic rules such as minimum lengths and positive numbers
  3. ๐Ÿงช Custom validators enforce additional input rules
  4. ๐Ÿ“ฅ Request schemas define what the frontend can send
  5. ๐Ÿ“ค Response schemas define what the backend returns
  6. ๐Ÿ”„ Serialization converts Python model data into dictionaries or JSON
  7. ๐Ÿท๏ธ Aliases let Python and JavaScript follow different naming conventions
  8. โšก FastAPI integrates all of this into the request-response lifecycle
  9. ๐Ÿ—„๏ธ SQLAlchemy handles database persistence; Pydantic handles API data validation and serialization
  10. ๐Ÿง  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)