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
BaseModelfor 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()andexclude_none=True@field_validatorfor custom validationmodel_configsettings
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 countGET /posts— get all posts, filter bypublishedandauthorGET /posts/{post_id}— get single postPATCH /posts/{post_id}— partial updateDELETE /posts/{post_id}— deletePOST /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