FastAPI Model Serialization
Introduction
When building APIs with FastAPI, one of the most crucial aspects is transforming data between different formats - particularly from Python objects to JSON for API responses, and from JSON to Python objects for incoming requests. This process is known as serialization (Python → JSON) and deserialization (JSON → Python).
FastAPI uses Pydantic models at its core to handle this data conversion automatically, making it much easier to validate, serialize, and document your API data. In this tutorial, we'll explore how FastAPI handles model serialization using Pydantic and how you can customize this process to suit your application needs.
Understanding Model Serialization in FastAPI
What is Serialization?
Serialization is the process of converting complex data structures (like Python objects) into formats that can be easily stored or transmitted (like JSON strings). In the context of FastAPI:
- Serialization: Converting Python objects to JSON to send as API responses
- Deserialization: Converting incoming JSON data to Python objects based on your models
FastAPI handles both processes automatically using Pydantic, which provides type checking and data validation along the way.
Basic Model Serialization
Let's start with a simple example of how FastAPI uses Pydantic for serialization:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List, Optional
app = FastAPI()
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
is_available: bool = True
tags: List[str] = []
@app.post("/items/")
async def create_item(item: Item):
return item
In this example:
- We define a Pydantic model
Item
- When a POST request is made to
/items/
, FastAPI automatically:- Deserializes the JSON request body into an
Item
instance - Validates the data against the model's types and constraints
- Returns the item, which FastAPI automatically serializes back to JSON
- Deserializes the JSON request body into an
Example Request and Response
Let's see what happens when we make a request to this endpoint:
Request:
{
"name": "Laptop",
"price": 999.99,
"description": "A powerful development machine",
"tags": ["electronics", "computers"]
}
Response:
{
"name": "Laptop",
"description": "A powerful development machine",
"price": 999.99,
"is_available": true,
"tags": ["electronics", "computers"]
}
FastAPI automatically handled deserializing the JSON input into a Pydantic Item
object, then serialized the object back to JSON in the response, including default values for fields not provided in the request.
Customizing Serialization
Model Config and Field Customization
Pydantic provides several ways to customize how your models serialize. One common approach is through the model's Config
class:
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional
app = FastAPI()
class Product(BaseModel):
id: int
name: str
price: float = Field(..., gt=0)
description: Optional[str] = None
class Config:
# Exclude unset fields from serialized output
exclude_unset = True
# Schema example for documentation
schema_extra = {
"example": {
"id": 123,
"name": "Premium Headphones",
"price": 129.99,
"description": "Noise-cancelling wireless headphones"
}
}
@app.post("/products/")
async def create_product(product: Product):
return product
With exclude_unset=True
, fields not included in the input won't appear in the output when the model is serialized.
Field Aliases
Sometimes the JSON field names in your API need to differ from your Python attribute names. Field aliases solve this problem:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class User(BaseModel):
user_id: int = Field(..., alias="userId")
full_name: str = Field(..., alias="fullName")
email_address: str = Field(..., alias="email")
class Config:
# Allow population by alias
allow_population_by_field_name = True
@app.post("/users/")
async def create_user(user: User):
return user
With this setup, you can:
- Accept JSON with camelCase fields (
userId
,fullName
,email
) - Work with snake_case attributes in your Python code (
user_id
,full_name
,email_address
) - Return camelCase in your API responses
Example Request and Response with Aliases
Request:
{
"userId": 1001,
"fullName": "Jane Smith",
"email": "[email protected]"
}
Response:
{
"userId": 1001,
"fullName": "Jane Smith",
"email": "[email protected]"
}
Custom Serialization Methods
For more advanced serialization control, Pydantic models support custom serialization methods:
Using model_dump()
The model_dump()
method (which replaces the deprecated dict()
method in newer Pydantic versions) converts a Pydantic model to a dictionary:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Dict, Any
from datetime import datetime
app = FastAPI()
class Order(BaseModel):
id: int
items: list[str]
created_at: datetime = datetime.now()
@app.post("/orders/")
async def create_order(order: Order):
# Custom processing on the model
order_dict = order.model_dump()
# Add calculated fields
order_dict["item_count"] = len(order.items)
order_dict["processed_at"] = datetime.now().isoformat()
return order_dict
Using model_dump_json()
For direct JSON serialization:
from fastapi import FastAPI, Response
from pydantic import BaseModel
from typing import List
from datetime import date
app = FastAPI()
class Event(BaseModel):
name: str
date: date
attendees: List[str]
@app.post("/events/")
async def create_event(event: Event):
# Custom JSON with specific formatting
json_data = event.model_dump_json(indent=2)
return Response(content=json_data, media_type="application/json")
Handling Complex Data Types
Date and DateTime Serialization
FastAPI automatically handles common data types like datetime
, date
, and UUID
:
from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime, date
import uuid
app = FastAPI()
class Appointment(BaseModel):
id: uuid.UUID = uuid.uuid4()
title: str
date: date
time: datetime
duration_minutes: int
@app.post("/appointments/")
async def create_appointment(appointment: Appointment):
return appointment
Request:
{
"title": "Dental Check-up",
"date": "2023-04-15",
"time": "2023-04-15T14:30:00",
"duration_minutes": 30
}
Response:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "Dental Check-up",
"date": "2023-04-15",
"time": "2023-04-15T14:30:00",
"duration_minutes": 30
}
Custom Type Encoders
For types not automatically handled by Pydantic, you can define custom encoders:
from fastapi import FastAPI
from pydantic import BaseModel, field_serializer
from datetime import timedelta
app = FastAPI()
class TrainingSession(BaseModel):
name: str
duration: timedelta
# Custom serializer for timedelta
@field_serializer('duration')
def serialize_duration(self, duration: timedelta):
# Convert to minutes for the API
return round(duration.total_seconds() / 60)
@app.post("/training/")
async def create_training(session: TrainingSession):
return session
Request:
{
"name": "Interval Training",
"duration": "00:45:00"
}
Response:
{
"name": "Interval Training",
"duration": 45
}
Recursive Models and Relationships
FastAPI handles recursive data structures and relationships between models:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List, Optional
app = FastAPI()
class Comment(BaseModel):
id: int
text: str
replies: List['Comment'] = []
# This is needed for recursive Pydantic models
Comment.model_rebuild()
class Post(BaseModel):
id: int
title: str
content: str
comments: List[Comment] = []
@app.post("/posts/")
async def create_post(post: Post):
return post
Schema Customization for Documentation
FastAPI uses your Pydantic models to generate OpenAPI documentation. You can customize this:
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field
from typing import Optional
app = FastAPI()
class Product(BaseModel):
name: str = Field(
...,
title="Product Name",
description="The name of the product",
max_length=50
)
price: float = Field(
...,
gt=0,
description="The price must be greater than zero"
)
description: Optional[str] = Field(
None,
title="Product Description",
description="Optional detailed description of the product"
)
@app.post(
"/products/",
response_description="The created product",
summary="Create a new product",
description="Create a new product with the provided details."
)
async def create_product(
product: Product = Body(
...,
examples={
"basic": {
"summary": "A basic example",
"value": {
"name": "Smartphone",
"price": 699.99
}
},
"complete": {
"summary": "A complete example",
"description": "A complete product with all fields",
"value": {
"name": "Premium Smartphone",
"price": 999.99,
"description": "The latest flagship smartphone with advanced features"
}
}
}
)
):
return product
Real-world Application Example: E-commerce API
Let's build a more complete example of an e-commerce product API with proper serialization:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, validator, field_serializer
from typing import List, Optional, Dict
from datetime import datetime
from enum import Enum
import uuid
app = FastAPI()
# Database simulation
products_db = {}
class ProductCategory(str, Enum):
ELECTRONICS = "electronics"
CLOTHING = "clothing"
BOOKS = "books"
HOME = "home"
OTHER = "other"
class ProductImage(BaseModel):
url: str
alt_text: Optional[str] = None
class ProductVariant(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
name: str
sku: str
price: float
stock_quantity: int
@validator('price')
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Price must be positive')
return v
class Product(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
name: str
description: str
base_price: float
category: ProductCategory
tags: List[str] = []
images: List[ProductImage] = []
variants: List[ProductVariant] = []
created_at: datetime = Field(default_factory=datetime.now)
updated_at: Optional[datetime] = None
@field_serializer('created_at', 'updated_at')
def serialize_datetime(self, dt: Optional[datetime]):
if dt:
return dt.isoformat()
return None
class Config:
schema_extra = {
"example": {
"name": "Comfortable Cotton T-Shirt",
"description": "A premium cotton t-shirt that's perfect for everyday wear",
"base_price": 19.99,
"category": "clothing",
"tags": ["cotton", "t-shirt", "casual"],
"images": [
{"url": "https://example.com/tshirt1.jpg", "alt_text": "Front view"}
],
"variants": [
{
"name": "Small Black",
"sku": "TS-BLK-S",
"price": 19.99,
"stock_quantity": 25
}
]
}
}
class ProductResponse(BaseModel):
product: Product
available_variants: int
total_stock: int
lowest_price: float
highest_price: float
@app.post("/products/", response_model=ProductResponse)
async def create_product(product: Product):
# Simulate saving to database
products_db[product.id] = product
# Calculate derived fields for response
available_variants = len(product.variants)
total_stock = sum(variant.stock_quantity for variant in product.variants) if product.variants else 0
if product.variants:
prices = [variant.price for variant in product.variants]
lowest_price = min(prices)
highest_price = max(prices)
else:
lowest_price = highest_price = product.base_price
# Return enriched response
return ProductResponse(
product=product,
available_variants=available_variants,
total_stock=total_stock,
lowest_price=lowest_price,
highest_price=highest_price
)
@app.get("/products/{product_id}", response_model=ProductResponse)
async def get_product(product_id: str):
if product_id not in products_db:
raise HTTPException(status_code=404, detail="Product not found")
product = products_db[product_id]
# Calculate same derived fields
available_variants = len(product.variants)
total_stock = sum(variant.stock_quantity for variant in product.variants) if product.variants else 0
if product.variants:
prices = [variant.price for variant in product.variants]
lowest_price = min(prices)
highest_price = max(prices)
else:
lowest_price = highest_price = product.base_price
return ProductResponse(
product=product,
available_variants=available_variants,
total_stock=total_stock,
lowest_price=lowest_price,
highest_price=highest_price
)
This e-commerce example demonstrates:
- Complex nested Pydantic models with relationships (
Product
containingProductVariant
andProductImage
) - Custom field validation with
validator
- Custom serialization with
field_serializer
- Enum usage for categorical data
- Response model customization with derived fields
- Schema documentation with examples
Summary
FastAPI's model serialization, powered by Pydantic, allows you to:
- Automatically convert between JSON and Python objects
- Validate incoming data against type definitions and constraints
- Customize field names, aliases, and serialization formats
- Handle complex data types and relationships
- Generate comprehensive API documentation
By leveraging Pydantic models for serialization, you can create robust APIs with less code and fewer bugs, while maintaining complete control over how your data is transformed and validated.
Additional Resources and Exercises
Resources
- FastAPI Documentation on Response Models
- Pydantic Documentation on Serialization
- JSON Schema Specification
Exercises
-
Basic Serialization: Create a FastAPI application with a
User
model that includes fields for name, email, age, and registration date. Implement endpoints to create and retrieve users. -
Custom Serialization: Extend the user model to include a password field that should never be included in API responses. Implement proper serialization to exclude sensitive data.
-
Complex Relationships: Build a blog API with models for
Post
,Author
, andComment
with proper relationships between them. Implement endpoints that return nested data. -
Transformation Challenge: Create a model that accepts dates in multiple formats (e.g., "YYYY-MM-DD", "MM/DD/YYYY") and always outputs them in ISO format.
-
API Response Customization: Implement an API endpoint that can return user data in different formats based on a query parameter (e.g.,
?format=detailed
vs?format=summary
).
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)