No history yet

Production Architecture and Pydantic

Structuring for Scale

When you first start with FastAPI, it's common to put everything in a single main.py file. This works for simple examples, but real-world applications quickly become tangled and difficult to manage. To build a maintainable API, we need a structure that separates concerns. A professional project organizes code by its function, not just its existence.

A good project structure makes your code predictable. When another developer joins your team, they shouldn't have to guess where to find database models, API endpoints, or configuration settings.

We'll adopt a standard pattern using an app directory. This isolates the application logic from other project files like tests, documentation, or deployment scripts. Inside app, we create subdirectories for different responsibilities.

DirectoryPurpose
app/routersContains the API endpoint logic, broken down by resource (e.g., items.py, users.py).
app/schemasHolds the Pydantic models that define the shape of your data for requests and responses.
app/coreManages core application concerns like configuration and settings.
# app/main.py
from fastapi import FastAPI
from .routers import items

app = FastAPI()

# Include the router from the items module
app.include_router(items.router)


# app/routers/items.py
from fastapi import APIRouter

router = APIRouter(
    prefix="/items", # All routes in this file will start with /items
    tags=["items"] # Group these endpoints in the docs
)

@router.get("/")
async def read_items():
    return [{"name": "Plumbus"}, {"name": "Fleeb"}]

In main.py, we assemble the application. The key is app.include_router(). This line tells our main FastAPI instance to pull in all the routes defined in the items.py file. This approach keeps our main file clean and delegates endpoint logic to the appropriate router file.

Centralized Configuration

Hardcoding values like database connection strings or secret keys is a major security risk and makes your application inflexible. We need a way to manage these settings that is both secure and adaptable to different environments (development, testing, production).

This is where Pydantic's BaseSettings comes in. It allows you to define your configuration as a typed class. Pydantic will automatically read values from environment variables or a .env file, validating them against the types you've specified.

# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    # The name of the variable must match the environment variable
    DATABASE_URL: str
    SECRET_KEY: str
    API_PREFIX: str = "/api/v1"

    # This tells BaseSettings to load from a .env file
    model_config = SettingsConfigDict(env_file=".env")

# Create a single, importable instance
settings = Settings()

By creating a single settings instance, we can import it anywhere in our application to access configuration values. This avoids global variables and ensures all configuration is loaded and validated from one place. If an environment variable is missing or has the wrong type, the application will fail to start, preventing runtime errors.

Smarter Data Contracts

In a real API, the data you receive isn't always identical to the data you send back. When a user creates a product, they might send a name and price. When you return that product's data, you'll also include a server-generated id and created_at timestamp. Trying to use a single Pydantic model for both cases can get messy.

A cleaner pattern is to separate your schemas based on their purpose: one for creating data (input) and one for reading data (output). We can use standard class inheritance to keep our code (Don't Repeat Yourself).

# app/schemas/item.py
from pydantic import BaseModel, Field
from datetime import datetime

# Contains common fields
class ItemBase(BaseModel):
    name: str = Field(min_length=3, max_length=50)
    price: float = Field(gt=0) # Must be greater than 0
    description: str | None = None

# Schema for creating an item (input)
class ItemCreate(ItemBase):
    pass

# Schema for reading an item (output)
class ItemRead(ItemBase):
    id: int
    created_at: datetime

Here, ItemBase defines the shared fields. ItemCreate inherits from it without adding anything new—it's the pure data we expect from the client. ItemRead inherits the same base fields but adds the id and created_at fields that our database provides. This gives us strong, explicit contracts for our API endpoints.

Using response_model=ItemRead in your path operation decorator ensures you never accidentally leak internal data. FastAPI will filter the return data to match the schema's fields.

Advanced Validation and Fields

Pydantic v2 introduced powerful new ways to define complex validation and generate dynamic fields. Two key features are Annotated for reusable constraints and computed_field for creating fields on the fly.

Sometimes you have a specific type of data with its own validation rules that you use in many different models. For example, a username might always need to be between 3 and 20 characters and alphanumeric. Instead of repeating Field(min_length=3, max_length=20, pattern=...) everywhere, we can use Annotated to create a reusable type.

# app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, StringConstraints

# Create a reusable type with built-in validation
Username = Annotated[
    str, 
    StringConstraints(min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$")
]

class UserCreate(BaseModel):
    username: Username # Use our custom annotated type
    email: str

class UserRead(BaseModel):
    id: int
    username: Username

Now, Username isn't just a string; it's a string that carries its own validation rules. This makes your schemas cleaner and your validation logic more consistent.

Another powerful feature is the computed_field decorator. This lets you add a field to your model that is calculated from other fields, without ever storing it in your input data or database. This is perfect for creating summary fields or derived values.

Imagine an Item model with price and tax. You might want to include a price_with_tax field in your API response without adding it to your database table. A is the perfect tool.

# app/schemas/order.py
from pydantic import BaseModel, computed_field

class Order(BaseModel):
    price: float
    tax: float | None = 0.1

    @computed_field
    @property
    def price_with_tax(self) -> float:
        """Calculate the total price including tax."""
        return self.price * (1 + self.tax)

When you serialize an Order instance, it will now have three fields: price, tax, and the dynamically calculated price_with_tax. This logic lives right next to the data it depends on, making your code easy to understand and maintain as it scales.