Skip to main content

FastAPI Complex Models

Introduction

When building APIs with FastAPI, you'll often need to work with data structures more complex than simple key-value pairs. This is where FastAPI's integration with Pydantic shines, allowing you to define, validate, and document complex data models with ease.

In this tutorial, we'll explore how to work with complex Pydantic models in FastAPI, covering nested models, relationships between models, model inheritance, and advanced validation techniques. By mastering these concepts, you'll be able to build robust APIs that handle complex data structures effectively.

Prerequisites

Before we begin, make sure you have:

  • Python 3.7+ installed
  • FastAPI and Uvicorn installed (pip install fastapi uvicorn)
  • Basic knowledge of Python and FastAPI

Basic Model Review

Let's start with a quick review of how basic Pydantic models work in FastAPI:

python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
name: str
price: float
is_offer: bool = False

@app.post("/items/")
def create_item(item: Item):
return item

This example shows a simple Item model with three fields. Now, let's dive into more complex scenarios.

Nested Models

One of the most common complex model patterns is nesting one model inside another. This is useful when your data has hierarchical structures.

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

app = FastAPI()

class Address(BaseModel):
street: str
city: str
state: str
zip_code: str
country: str

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

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

In this example, the User model contains an Address model. When you send a request to create a user, you would use JSON like this:

json
{
"username": "johndoe",
"email": "[email protected]",
"full_name": "John Doe",
"address": {
"street": "123 Main St",
"city": "Anytown",
"state": "CA",
"zip_code": "12345",
"country": "USA"
}
}

FastAPI will automatically validate both the outer and nested models, ensuring all required fields are present and have the correct types.

Lists of Models

Often, you'll need to work with lists of objects. Pydantic makes this easy with Python's typing system:

python
from typing import List

class Tag(BaseModel):
name: str
value: str

class Product(BaseModel):
name: str
description: Optional[str] = None
price: float
tags: List[Tag] = []

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

A valid request for this endpoint would look like:

json
{
"name": "Smartphone",
"description": "Latest model with advanced features",
"price": 999.99,
"tags": [
{"name": "electronics", "value": "high-tech"},
{"name": "mobile", "value": "phone"}
]
}

Model Inheritance

Pydantic supports model inheritance, which is useful for creating hierarchies of related models:

python
class BaseItem(BaseModel):
name: str
description: Optional[str] = None

class BookItem(BaseItem):
author: str
pages: int

class ElectronicItem(BaseItem):
brand: str
warranty_years: int

@app.post("/books/")
def create_book(book: BookItem):
return book

@app.post("/electronics/")
def create_electronic(electronic: ElectronicItem):
return electronic

Both BookItem and ElectronicItem inherit fields from BaseItem while adding their own specific fields.

Unions of Models

Sometimes, an API endpoint needs to accept different types of models. You can use Python's Union type for this:

python
from typing import Union

@app.post("/items/")
def create_item(item: Union[BookItem, ElectronicItem]):
if isinstance(item, BookItem):
return {"item_type": "book", "item": item}
else:
return {"item_type": "electronic", "item": item}

This endpoint can accept either a BookItem or an ElectronicItem. FastAPI will try to validate the incoming data against both models and use the first one that matches.

Recursive Models

Sometimes you need to define models that reference themselves recursively. A common example is a tree structure:

python
from typing import List, Optional

class TreeNode(BaseModel):
value: str
children: Optional[List["TreeNode"]] = None

# Required when using self-referencing models
TreeNode.update_forward_refs()

@app.post("/nodes/")
def create_node(node: TreeNode):
return node

The update_forward_refs() call is necessary to resolve the forward reference in List["TreeNode"].

Working with Dictionaries

For flexible schemas where some fields aren't known in advance, you can use dictionaries:

python
from typing import Dict, Any

class DynamicModel(BaseModel):
name: str
# Additional properties can be any valid JSON
properties: Dict[str, Any]

@app.post("/dynamic/")
def create_dynamic(model: DynamicModel):
return model

A valid request might look like:

json
{
"name": "Custom Object",
"properties": {
"color": "blue",
"size": 42,
"features": ["waterproof", "shockproof"],
"metadata": {
"created_by": "user123",
"version": 2.1
}
}
}

Advanced Validation

Pydantic offers advanced validation capabilities that you can use with your models:

python
from pydantic import BaseModel, Field, validator, root_validator
from typing import List
import re

class AdvancedUser(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=8)
email: str
tags: List[str] = []

@validator('email')
def email_must_be_valid(cls, v):
if not re.match(r"[^@]+@[^@]+\.[^@]+", v):
raise ValueError('Invalid email format')
return v

@validator('tags')
def tags_must_not_be_empty(cls, v):
if len(v) > 5:
raise ValueError('A maximum of 5 tags is allowed')
return v

@root_validator
def check_password_strength(cls, values):
password = values.get('password')
username = values.get('username')

if password and username and username in password:
raise ValueError('Password cannot contain the username')

return values

@app.post("/advanced-users/")
def create_advanced_user(user: AdvancedUser):
return user

This example demonstrates several validation techniques:

  1. Field parameters for length constraints
  2. @validator decorators for field-specific validation
  3. @root_validator for validations that involve multiple fields

Practical Example: E-commerce API

Let's put everything together in a more realistic e-commerce API example:

python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Dict, Any
from datetime import datetime
from enum import Enum
import uuid

app = FastAPI()

class Category(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
price: float = Field(..., gt=0)
stock_count: int = Field(..., ge=0)

@validator('price')
def price_must_be_reasonable(cls, v):
if v > 1000000:
raise ValueError('Price seems unreasonably high')
return v

class Product(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=1000)
category: Category
main_image: Optional[ProductImage] = None
additional_images: List[ProductImage] = []
variants: List[ProductVariant] = []
tags: List[str] = []
metadata: Dict[str, Any] = {}
created_at: datetime = Field(default_factory=datetime.now)

@validator('variants')
def at_least_one_variant(cls, v):
if len(v) == 0:
raise ValueError('At least one product variant is required')
return v

class Address(BaseModel):
street: str
city: str
state: str
postal_code: str
country: str

class OrderStatus(str, Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"

class OrderItem(BaseModel):
product_id: str
variant_id: str
quantity: int = Field(..., gt=0)
price_per_unit: float = Field(..., gt=0)

class Order(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
user_id: str
items: List[OrderItem]
total_amount: float
status: OrderStatus = OrderStatus.PENDING
shipping_address: Address
billing_address: Optional[Address] = None
created_at: datetime = Field(default_factory=datetime.now)

@root_validator
def calculate_total(cls, values):
items = values.get('items', [])
calculated_total = sum(item.price_per_unit * item.quantity for item in items)
total_amount = values.get('total_amount')

if abs(calculated_total - total_amount) > 0.01:
raise ValueError(f'Total amount ({total_amount}) does not match calculated total ({calculated_total})')

return values

# API endpoints
@app.post("/products/", response_model=Product)
def create_product(product: Product):
# In a real app, you would save this to a database
return product

@app.post("/orders/", response_model=Order)
def create_order(order: Order):
# In a real app, you would validate product availability, pricing, etc.
return order

This example demonstrates a robust e-commerce API with complex models for products and orders, including:

  • Enumeration types for categories and order statuses
  • Nested models for product images, variants, and addresses
  • UUID generation for IDs
  • Various validation methods including field constraints and custom validators
  • Relationships between models (orders contain items that reference products)

Best Practices for Complex Models

When working with complex models in FastAPI, keep these best practices in mind:

  1. Break down large models into smaller, reusable components
  2. Use inheritance to share common fields between related models
  3. Add validation to ensure data integrity early in the request process
  4. Set default values where appropriate to make API usage more convenient
  5. Document your models with field descriptions and examples
  6. Consider performance with very deep or large models
  7. Update forward references when using self-referential models
  8. Use optional fields instead of creating multiple similar models

Summary

In this tutorial, we've explored how to work with complex Pydantic models in FastAPI:

  • Nested models for hierarchical data structures
  • Lists and collections of models
  • Model inheritance for reusable field definitions
  • Unions of different model types
  • Recursive models for self-referential structures
  • Dictionaries for flexible schemas
  • Advanced validation techniques
  • A practical e-commerce API example

FastAPI's integration with Pydantic provides a powerful system for defining, validating, and documenting complex data models. By leveraging these features, you can build robust APIs that clearly communicate their data requirements and provide helpful feedback to clients.

Additional Resources

Exercises

  1. Create a model for a blog post system with nested comments that can be nested further (recursive comments)
  2. Design a model for a file system with directories and files, where directories can contain both files and other directories
  3. Implement a complex validation system for a user registration form that validates password strength, checks email format, and ensures username uniqueness
  4. Build an inventory management system API with products, categories, suppliers, and warehouse locations
  5. Extend the e-commerce example with customer reviews and ratings models

By completing these exercises, you'll gain practical experience working with complex models in FastAPI and be well-prepared to build sophisticated APIs for real-world applications.



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