Request & Response with Pydantic in Fast APIs

What is Pydantic?

In Stage 2 you passed data as query parameters in the URL:

POST /users?name=Gagan&email=gagan@email.com&age=22

This works for simple cases but real APIs send data in the request body as JSON:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "age": 22
}

Pydantic is the library that handles this. It lets you define exactly what shape your data should be — and automatically validates everything.

FastAPI is built on top of Pydantic. They work together seamlessly.


Installing Pydantic

Pydantic comes installed with FastAPI automatically. Nothing extra needed.


Your First Pydantic Model


    from pydantic import BaseModel

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

That's a Pydantic model. It looks exactly like a Python class with type hints. BaseModel is what makes it a Pydantic model.

Now use it in a route:


    from fastapi import FastAPI
    from pydantic import BaseModel

    app = FastAPI()

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

    @app.post("/users")
    def create_user(user: User):
        return user

Now FastAPI knows this route expects a JSON body matching the User shape. It will:

  • Automatically parse the JSON body
  • Validate every field type
  • Return clear error if anything is wrong
  • Pass the validated data to your function

Testing With Request Body

Open /docs — the POST route now shows a proper JSON form instead of individual query params.

Send this JSON body:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "age": 22
}

Response:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "age": 22
}

Now try sending wrong data:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "age": "not-a-number"
}

FastAPI automatically returns:

{
    "detail": [
        {
            "type": "int_parsing",
            "loc": ["body", "age"],
            "msg": "Input should be a valid integer",
            "input": "not-a-number"
        }
    ]
}

Zero code written for validation — Pydantic handles it all.


Accessing Model Fields

The user parameter is a Pydantic model object. Access fields with dot notation:


    users_db = []

    @app.post("/users", status_code=201)
    def create_user(user: User):
        new_id = len(users_db) + 1

        user_dict = {
            "id": new_id,
            "name": user.name,
            "email": user.email,
            "age": user.age
        }

        users_db.append(user_dict)
        return user_dict

Or convert to dict directly using .model_dump():


    @app.post("/users", status_code=201)
    def create_user(user: User):
        new_id = len(users_db) + 1
        user_dict = user.model_dump()    # converts to dictionary
        user_dict["id"] = new_id
        users_db.append(user_dict)
        return user_dict

.model_dump() is the modern Pydantic v2 method. You might see .dict() in older code — same thing but deprecated.


Optional Fields with Defaults

Not every field is required. Use default values:


    from pydantic import BaseModel

    class User(BaseModel):
        name: str                           # required
        email: str                          # required
        age: int                            # required
        city: str = "Unknown"               # optional with default
        is_active: bool = True              # optional with default
        bio: str | None = None              # optional, defaults to None

Now this JSON is valid:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "age": 22
}

city becomes "Unknown", is_active becomes True, bio becomes None.


Nested Models

Models can contain other models:


    class Address(BaseModel):
        street: str
        city: str
        state: str
        pincode: str

    class User(BaseModel):
        name: str
        email: str
        age: int
        address: Address              # nested model

Send this JSON:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "age": 22,
    "address": {
        "street": "123 MG Road",
        "city": "Delhi",
        "state": "Delhi",
        "pincode": "110001"
    }
}

Access it:


    @app.post("/users")
    def create_user(user: User):
        print(user.address.city)    # Delhi
        return user

Validation works on nested models too — automatically.


List Inside Model


    class User(BaseModel):
        name: str
        email: str
        skills: list[str] = []         # list of strings, defaults to empty list
        scores: list[int] = []

Valid JSON:

{
    "name": "Gagan",
    "email": "gagan@email.com",
    "skills": ["Python", "FastAPI", "PostgreSQL"],
    "scores": [85, 90, 78]
}

Field — Adding Validation Rules

Field lets you add extra validation rules and metadata to model fields:


    from pydantic import BaseModel, Field

    class User(BaseModel):
        name: str = Field(min_length=2, max_length=50)
        email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
        age: int = Field(ge=0, le=150)          # ge=greater or equal, le=less or equal
        salary: float = Field(gt=0)             # gt=greater than
        bio: str | None = Field(default=None, max_length=500)

Field constraints:

Constraint

Meaning

min_length

Minimum string length

max_length

Maximum string length

pattern

Regex pattern must match

gt

Greater than

ge

Greater than or equal

lt

Less than

le

Less than or equal

Now if you send age: -5 — FastAPI automatically rejects it with a clear error. No if statements needed in your code.


Field with Description and Example

This improves your auto-generated docs:


    class User(BaseModel):
        name: str = Field(
            min_length=2,
            max_length=50,
            description="Full name of the user",
            examples=["Gagan Singh"]
        )
        email: str = Field(
            description="Valid email address",
            examples=["gagan@email.com"]
        )
        age: int = Field(
            ge=0,
            le=150,
            description="Age in years",
            examples=[22]
        )

Open /docs — your fields now show descriptions and example values. Much better documentation automatically.


Response Models

Right now your routes return raw dictionaries. Response models let you control exactly what gets returned — hiding sensitive fields like passwords.


    class UserCreate(BaseModel):
        name: str
        email: str
        password: str
        age: int

    class UserResponse(BaseModel):
        id: int
        name: str
        email: str
        age: int
        # notice: no password field here

    users_db = []

    @app.post("/users", response_model=UserResponse, status_code=201)
    def create_user(user: UserCreate):
        new_user = user.model_dump()
        new_user["id"] = len(users_db) + 1
        users_db.append(new_user)
        return new_user    # has password in it, but response_model filters it out

Even though new_user contains the password — FastAPI only returns fields defined in UserResponse. Password is automatically stripped. This is how real APIs work.


Multiple Models Pattern — Standard Practice

In real projects you have different models for different purposes:


    from pydantic import BaseModel, Field
    from datetime import datetime

    # What client sends to CREATE a user
    class UserCreate(BaseModel):
        name: str = Field(min_length=2, max_length=50)
        email: str
        password: str = Field(min_length=8)
        age: int = Field(ge=0, le=150)

    # What client sends to UPDATE a user
    class UserUpdate(BaseModel):
        name: str | None = Field(default=None, min_length=2)
        email: str | None = None
        age: int | None = Field(default=None, ge=0, le=150)

    # What API sends back to client
    class UserResponse(BaseModel):
        id: int
        name: str
        email: str
        age: int
        created_at: str

    # Internal model — stored in database
    class UserInDB(BaseModel):
        id: int
        name: str
        email: str
        password_hash: str    # stored as hash, never sent to client
        age: int
        created_at: str

This pattern is called DTO (Data Transfer Object) — you already know this concept from NestJS. Same idea, different syntax.


Complete API with Proper Pydantic Models

Let's rebuild the Users API properly:


    from fastapi import FastAPI, HTTPException, status
    from pydantic import BaseModel, Field
    from datetime import datetime

    app = FastAPI(title="Users API", version="2.0.0")

    # ── Models ────────────────────────────────────────
    class UserCreate(BaseModel):
        name: str = Field(min_length=2, max_length=50, examples=["Gagan Singh"])
        email: str = Field(examples=["gagan@email.com"])
        age: int = Field(ge=0, le=150, examples=[22])
        city: str = Field(default="Unknown", examples=["Delhi"])

    class UserUpdate(BaseModel):
        name: str | None = Field(default=None, min_length=2)
        email: str | None = None
        age: int | None = Field(default=None, ge=0, le=150)
        city: str | None = None

    class UserResponse(BaseModel):
        id: int
        name: str
        email: str
        age: int
        city: str
        created_at: str


    # ── In-memory DB ──────────────────────────────────
    users_db = []
    next_id = 1


    # ── Helper ───────────────────────────────────────
    def find_user(user_id: int) -> dict | None:
        for user in users_db:
            if user["id"] == user_id:
                return user
        return None


    # ── Routes ───────────────────────────────────────
    @app.get("/")
    def root():
        return {"message": "Users API v2 running", "total_users": len(users_db)}


    @app.get("/users", response_model=list[UserResponse])
    def get_all_users(skip: int = 0, limit: int = 10):
        return users_db[skip: skip + limit]


    @app.get("/users/search", response_model=list[UserResponse])
    def search_users(name: str | None = None, city: str | None = None):
        results = users_db
        if name:
            results = [u for u in results if name.lower() in u["name"].lower()]
        if city:
            results = [u for u in results if city.lower() in u["city"].lower()]
        return results


    @app.get("/users/{user_id}", response_model=UserResponse)
    def get_user(user_id: int):
        user = find_user(user_id)
        if not user:
            raise HTTPException(status_code=404, detail="User not found")
        return user


    @app.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
    def create_user(user: UserCreate):
        global next_id

        new_user = user.model_dump()
        new_user["id"] = next_id
        new_user["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        users_db.append(new_user)
        next_id += 1

        return new_user


    @app.put("/users/{user_id}", response_model=UserResponse)
    def update_user(user_id: int, user: UserCreate):
        existing = find_user(user_id)
        if not existing:
            raise HTTPException(status_code=404, detail="User not found")

        existing.update(user.model_dump())
        return existing


    @app.patch("/users/{user_id}", response_model=UserResponse)
    def partial_update_user(user_id: int, user: UserUpdate):
        existing = find_user(user_id)
        if not existing:
            raise HTTPException(status_code=404, detail="User not found")

        update_data = user.model_dump(exclude_none=True)    # exclude None fields
        existing.update(update_data)
        return existing


    @app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
    def delete_user(user_id: int):
        user = find_user(user_id)
        if not user:
            raise HTTPException(status_code=404, detail="User not found")
        users_db.remove(user)

Notice .model_dump(exclude_none=True) — this is the clean way to handle partial updates. It only returns fields that were actually provided, skipping None values.


model_config — Model Settings

You can add settings to your Pydantic model:


    class UserResponse(BaseModel):
        id: int
        name: str
        email: str

        model_config = {
            "from_attributes": True    # allows creating model from ORM objects
        }                              # you'll need this when we add database

from_attributes=True is important for Stage 5 when we use SQLAlchemy — it lets Pydantic read data from database model objects directly.


Validators — Custom Validation Logic

Sometimes built-in constraints are not enough. Write custom validators:


    from pydantic import BaseModel, Field, field_validator

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

        @field_validator("email")
        @classmethod
        def email_must_be_valid(cls, value: str) -> str:
            if "@" not in value or "." not in value:
                raise ValueError("Invalid email format")
            return value.lower().strip()    # normalize email

        @field_validator("username")
        @classmethod
        def username_must_be_alphanumeric(cls, value: str) -> str:
            if not value.isalnum():
                raise ValueError("Username must contain only letters and numbers")
            return value.lower()           # normalize to lowercase

        @field_validator("age")
        @classmethod
        def age_must_be_valid(cls, value: int) -> int:
            if value < 0:
                raise ValueError("Age cannot be negative")
            if value > 120:
                raise ValueError("Age seems unrealistic")
            return value

@field_validator("field_name") runs your custom logic after the basic type check. If you raise ValueError — Pydantic returns it as a validation error automatically.


Computed Fields

Sometimes you want to add fields that are calculated from other fields:


    from pydantic import BaseModel, computed_field

    class Product(BaseModel):
        name: str
        price: float
        tax_rate: float = 0.18

        @computed_field
        @property
        def price_with_tax(self) -> float:
            return round(self.price * (1 + self.tax_rate), 2)

        @computed_field
        @property
        def tax_amount(self) -> float:
            return round(self.price * self.tax_rate, 2)


    @app.post("/products")
    def create_product(product: Product):
        return product

    # Send: {"name": "Laptop", "price": 50000}
    # Get back: {"name": "Laptop", "price": 50000, "tax_rate": 0.18,
    #            "price_with_tax": 59000.0, "tax_amount": 9000.0}


Real World Example — E-commerce Product API

Putting everything together:


    from fastapi import FastAPI, HTTPException, status
    from pydantic import BaseModel, Field, field_validator, computed_field
    from datetime import datetime

    app = FastAPI(title="Products API")


    class ProductCreate(BaseModel):
        name: str = Field(min_length=2, max_length=100)
        description: str | None = Field(default=None, max_length=500)
        price: float = Field(gt=0)
        category: str = Field(min_length=2)
        stock: int = Field(ge=0, default=0)
        discount_percent: float = Field(ge=0, le=100, default=0)

        @field_validator("name")
        @classmethod
        def name_must_not_be_blank(cls, value: str) -> str:
            if not value.strip():
                raise ValueError("Name cannot be blank")
            return value.strip().title()

        @field_validator("category")
        @classmethod
        def normalize_category(cls, value: str) -> str:
            return value.strip().lower()


    class ProductResponse(BaseModel):
        id: int
        name: str
        description: str | None
        price: float
        discounted_price: float
        category: str
        stock: int
        in_stock: bool
        created_at: str

        model_config = {"from_attributes": True}


    products_db = []
    next_id = 1


    @app.post("/products", response_model=ProductResponse, status_code=201)
    def create_product(product: ProductCreate):
        global next_id

        data = product.model_dump()
        price = data["price"]
        discount = data["discount_percent"]
        discounted_price = round(price * (1 - discount / 100), 2)

        new_product = {
            **data,
            "id": next_id,
            "discounted_price": discounted_price,
            "in_stock": data["stock"] > 0,
            "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        }

        products_db.append(new_product)
        next_id += 1
        return new_product


    @app.get("/products", response_model=list[ProductResponse])
    def get_products(
        category: str | None = None,
        in_stock: bool | None = None,
        min_price: float | None = None,
        max_price: float | None = None
    ):
        results = products_db

        if category:
            results = [p for p in results if p["category"] == category.lower()]
        if in_stock is not None:
            results = [p for p in results if p["in_stock"] == in_stock]
        if min_price is not None:
            results = [p for p in results if p["price"] >= min_price]
        if max_price is not None:
            results = [p for p in results if p["price"] <= max_price]

        return results


    @app.get("/products/{product_id}", response_model=ProductResponse)
    def get_product(product_id: int):
        for product in products_db:
            if product["id"] == product_id:
                return product
        raise HTTPException(status_code=404, detail="Product not found")

Test by creating a product:

{
    "name": "gaming laptop",
    "description": "High performance laptop",
    "price": 75000,
    "category": "Electronics",
    "stock": 10,
    "discount_percent": 10
}

Response automatically has discounted_price: 67500, in_stock: true, category: "electronics" (normalized), name: "Gaming Laptop" (title cased).


Summary — What You Learned

  • Pydantic BaseModel for request body
  • Optional fields with defaults
  • Nested models
  • Field() for validation constraints
  • Response models with response_model
  • Multiple model pattern (Create, Update, Response)
  • model_dump() and exclude_none=True
  • @field_validator for custom validation
  • model_config settings

Exercise 🏋️

Build a Blog Posts API with proper Pydantic models:

Create these models:

# What client sends
class PostCreate:
    title: str          # required, 5-100 chars
    content: str        # required, min 20 chars
    author: str         # required
    tags: list[str]     # optional, default empty list
    published: bool     # optional, default False

# What client sends for update
class PostUpdate:
    title: str | None
    content: str | None
    published: bool | None

# What API returns
class PostResponse:
    id: int
    title: str
    content: str
    author: str
    tags: list[str]
    published: bool
    word_count: int        # calculated from content
    created_at: str

Build these routes:

  • POST /posts — create post, auto-calculate word count
  • GET /posts — get all posts, filter by published and author
  • GET /posts/{post_id} — get single post
  • PATCH /posts/{post_id} — partial update
  • DELETE /posts/{post_id} — delete
  • POST /posts/{post_id}/publish — special route to publish a post

Add validators:

  • Title cannot be all numbers
  • Tags should be normalized to lowercase


No comments:

Post a Comment

Request & Response with Pydantic in Fast APIs

What is Pydantic? In Stage 2 you passed data as query parameters in the URL: POST /users?name=Gagan&email=gagan@email.com&age=22 ...