Skip to main content

FastAPI Extra Models

Introduction

When building real-world APIs, you'll often need to work with multiple related models rather than just a single data structure. FastAPI provides elegant ways to define, relate, and use multiple Pydantic models to create complex yet maintainable API schemas.

In this tutorial, we'll explore how to work with multiple models in FastAPI, establish relationships between them, and use them effectively in your API endpoints. This is a crucial skill for building robust applications with proper data separation and organization.

Why Use Multiple Models?

Before diving into the code, let's understand why we might need multiple models:

  1. Input vs Output: Often the data structure you receive might differ from what you return
  2. Data Validation: Different endpoints might require different validation rules
  3. Documentation: Multiple models help create clearer, more specific API documentation
  4. Security: You can exclude sensitive fields from response models
  5. Database Integration: Your database models might not match your API models

Basic Multiple Model Example

Let's start with a simple example of using different models for input and output:

python
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
from typing import List, Optional

app = FastAPI()

class UserIn(BaseModel):
username: str
email: EmailStr
password: str
full_name: Optional[str] = None

class UserOut(BaseModel):
username: str
email: EmailStr
full_name: Optional[str] = None

@app.post("/users/", response_model=UserOut)
async def create_user(user: UserIn):
# In a real app, you would save the user to a database here
# Note that we return the input user, but FastAPI will filter
# out the password field because we use UserOut as response_model
return user

In this example:

  • UserIn includes password and is used for input validation
  • UserOut excludes password and is used for the response
  • The response_model=UserOut parameter ensures that FastAPI filters the output to match UserOut

When testing with a request body like:

json
{
"username": "johndoe",
"email": "[email protected]",
"password": "secret123",
"full_name": "John Doe"
}

The API will return:

json
{
"username": "johndoe",
"email": "[email protected]",
"full_name": "John Doe"
}

Advanced Model Relationships

Real applications often have complex relationships between models. Let's explore some common patterns:

Nested Models

You can define models that contain other models:

python
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
tax: Optional[float] = None

class User(BaseModel):
username: str
full_name: Optional[str] = None
email: str

class Order(BaseModel):
items: List[Item]
customer: User

@app.post("/orders/")
async def create_order(order: Order):
return {"order_id": "123", "order": order}

With this structure, your API will expect a request body like:

json
{
"items": [
{
"name": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"tax": 81.00
}
],
"customer": {
"username": "johndoe",
"full_name": "John Doe",
"email": "[email protected]"
}
}

Union Type Models

Sometimes you might need to accept different types of models at the same endpoint. For example, a payment API might handle different payment methods:

python
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Union

app = FastAPI()

class CreditCardPayment(BaseModel):
card_number: str
expiry_date: str
security_code: str

class BankTransferPayment(BaseModel):
account_number: str
routing_number: str

class PaymentResponse(BaseModel):
payment_id: str
amount: float
status: str

@app.post("/payments/", response_model=PaymentResponse)
async def process_payment(payment: Union[CreditCardPayment, BankTransferPayment]):
# Process payment based on type
if isinstance(payment, CreditCardPayment):
# Process credit card
payment_method = "credit_card"
else:
# Process bank transfer
payment_method = "bank_transfer"

return {
"payment_id": "pay_123456",
"amount": 100.50,
"status": f"Processing {payment_method} payment"
}

Model Inheritance

Pydantic models support inheritance, which is great for when you have models that share many fields:

python
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional, List

app = FastAPI()

class ItemBase(BaseModel):
name: str
description: Optional[str] = None
price: float

class ItemCreate(ItemBase):
tax: Optional[float] = None

class ItemInDB(ItemBase):
id: int
stock: int

@app.post("/items/", response_model=ItemInDB)
async def create_item(item: ItemCreate):
# Simulate creating an item in database and returning it
return ItemInDB(
id=1,
stock=20,
name=item.name,
description=item.description,
price=item.price
)

Using Extra Models for Database Operations

When working with databases, it's common to have distinct models for:

  1. Creating records (with validation)
  2. Reading records (for responses)
  3. Updating records (with partial data)
  4. Database models (ORM integration)

Here's an example using SQLAlchemy with FastAPI:

python
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List

# Database setup (simplified)
from database import get_db, Base
from sqlalchemy import Column, Integer, String, Float
from sqlalchemy.ext.declarative import declarative_base

app = FastAPI()

# SQLAlchemy model
class ProductDB(Base):
__tablename__ = "products"

id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
description = Column(String)
price = Column(Float)
stock = Column(Integer)

# Pydantic models
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
price: float

class ProductCreate(ProductBase):
stock: int

class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
stock: Optional[int] = None

class Product(ProductBase):
id: int
stock: int

class Config:
orm_mode = True

@app.post("/products/", response_model=Product)
def create_product(product: ProductCreate, db: Session = Depends(get_db)):
db_product = ProductDB(**product.dict())
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product

@app.get("/products/{product_id}", response_model=Product)
def read_product(product_id: int, db: Session = Depends(get_db)):
db_product = db.query(ProductDB).filter(ProductDB.id == product_id).first()
if db_product is None:
raise HTTPException(status_code=404, detail="Product not found")
return db_product

@app.put("/products/{product_id}", response_model=Product)
def update_product(product_id: int, product: ProductUpdate, db: Session = Depends(get_db)):
db_product = db.query(ProductDB).filter(ProductDB.id == product_id).first()
if db_product is None:
raise HTTPException(status_code=404, detail="Product not found")

update_data = product.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_product, key, value)

db.commit()
db.refresh(db_product)
return db_product

In this example:

  • ProductBase contains common fields
  • ProductCreate is used for creating new products
  • ProductUpdate makes all fields optional for partial updates
  • Product is the response model with all fields including the ID
  • ProductDB is the SQLAlchemy ORM model

Using Generic Models

For APIs with common response patterns, you can create generic models to reduce repetition:

python
from fastapi import FastAPI
from pydantic import BaseModel, Generic, TypeVar
from typing import Optional, List, Dict, Any

T = TypeVar('T')

app = FastAPI()

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

class Item(BaseModel):
id: int
name: str
price: float

class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
page: int
size: int

class GenericResponse(BaseModel, Generic[T]):
data: T
status: str
message: Optional[str] = None
meta: Optional[Dict[str, Any]] = None

@app.get("/users/", response_model=PaginatedResponse[User])
async def list_users():
# Simulate database fetch
users = [
User(id=1, name="Alice", email="[email protected]"),
User(id=2, name="Bob", email="[email protected]"),
]
return PaginatedResponse(
items=users,
total=2,
page=1,
size=10
)

@app.get("/items/{item_id}", response_model=GenericResponse[Item])
async def get_item(item_id: int):
# Simulate database fetch
item = Item(id=item_id, name="Laptop", price=999.99)
return GenericResponse(
data=item,
status="success",
message="Item retrieved successfully",
meta={"accessed_at": "2023-07-15T15:30:00Z"}
)

This approach provides consistent responses across different endpoint types while maintaining type safety.

Summary

Working with multiple models in FastAPI offers several advantages:

  1. Security: Keep sensitive data out of responses
  2. Flexibility: Handle different input and output requirements
  3. Organization: Create logical separations between your data models
  4. Maintainability: Update input validation without affecting output models
  5. Documentation: Generate clear API documentation with specific schemas

By properly using extra models, you can create more robust, secure, and maintainable API designs that handle complex real-world requirements.

Exercises

To reinforce your understanding of extra models in FastAPI:

  1. Create an e-commerce API with models for products, customers, and orders with appropriate relationships between them.
  2. Implement a blog system with models for users, posts, and comments using inheritance to reduce code duplication.
  3. Design an authentication system with separate models for registration, login, and user profiles.
  4. Create a generic pagination response model and apply it to several endpoints.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)