Right, let’s talk structure. You can’t just throw your FastAPI code into a single main.py file and call it a day. Well, you can, and I have, but it’s a terrible idea that scales about as well as a chocolate teapot. The moment you need to add database models, route handlers, and configuration, that single file becomes an unreadable mess. Let’s build something that won’t make your future self (or your teammates) want to set your laptop on fire.

The goal is to create a logical separation of concerns. Your routes live in one place, your database models in another, your configuration and utility functions somewhere else. It makes your code easier to reason about, test, and maintain. It’s the difference between a neatly organized toolbox and one where you just threw a wrench, a live badger, and a soldering iron into a duffel bag.

The Project Layout: Your New Blueprint

Here’s a standard, sane project structure that will serve you well from a weekend project to a production-grade application. Create these directories and files at your project’s root:

my_fastapi_app/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── api/
│   │   ├── __init__.py
│   │   └── endpoints/
│   │       ├── __init__.py
│   │       ├── items.py
│   │       └── users.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   └── security.py
│   ├── models/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── item.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── item.py
│   └── db/
│       ├── __init__.py
│       └── session.py
├── tests/
│   ├── __init__.py
│   ├── test_items.py
│   └── test_users.py
├── requirements.txt
└── .env

Yes, there are a lot of __init__.py files. They’re mostly there to turn directories into proper Python packages so you can import things cleanly. It’s a bit of boilerplate, but it’s the tax we pay for civilization.

The Heart: app/main.py

This file is the entry point of your application. It should be lean, mean, and do almost nothing except create your FastAPI instance and include your routers. Its job is to assemble the pieces, not contain them.

from fastapi import FastAPI
from app.core.config import settings
from app.api.endpoints import items, users

app = FastAPI(title=settings.PROJECT_NAME, version=settings.PROJECT_VERSION)

app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(items.router, prefix="/items", tags=["items"])

@app.get("/")
async def root():
    return {"message": "Hello World. Your app is structured correctly. You're winning."}

Organizing Routes with APIRouter

This is arguably the most important pattern for keeping your code clean. Instead of decorating functions attached to the global app instance, you create separate routers for logical resource groups. This keeps your endpoint code modular and isolated. Here’s app/api/endpoints/items.py:

from fastapi import APIRouter, HTTPException, Depends
from typing import List

from app.schemas.item import Item, ItemCreate
from app.models.item import Item as DBItem
from app.db.session import get_db
from sqlalchemy.orm import Session

router = APIRouter()

# Notice we use `router.get`, not `app.get`. This is the way.
@router.get("/", response_model=List[Item])
def read_items(db: Session = Depends(get_db), skip: int = 0, limit: int = 100):
    items = db.query(DBItem).offset(skip).limit(limit).all()
    return items

@router.post("/", response_model=Item)
def create_item(*, db: Session = Depends(get_db), item_in: ItemCreate):
    # Your business logic for creating an item here
    db_item = DBItem(name=item_in.name, description=item_in.description)
    db.add(db_item)
    db.commit()
    db.refresh(db_item)
    return db_item

The Critical Pydantic vs. SQLAlchemy Split

This is a common point of confusion, and the designers of FastAPI were right to enforce this separation. You have two distinct layers:

  1. SQLAlchemy Models (app/models/): These classes represent your database tables. They are about the structure and relationships of your data as it exists in the database.
  2. Pydantic Schemas (app/schemas/): These classes define the shape of the data as it enters and leaves your API. They are used for request validation, response serialization, and OpenAPI documentation.

Mixing these concerns is a one-way ticket to pain. Your database model might have a hashed_password field, but you would never want that returned in an API response. Your schema for creating a user might have a password field, but you would never want to store that plaintext in the database. The separation is a feature, not a bug.

# app/schemas/user.py
from pydantic import BaseModel, EmailStr
from typing import Optional

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

class UserCreate(UserBase):
    password: str  # This is accepted from the API request

class User(UserBase):
    id: int
    is_active: bool

    class Config:
        orm_mode = True  # This is magic. It tells Pydantic to read from ORM objects, not just dicts.

# app/models/user.py
from sqlalchemy import Boolean, Column, Integer, String
from app.db.session import Base  # This is your SQLAlchemy declarative_base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True)
    hashed_password = Column(String)  # Notice the different name!
    full_name = Column(String, nullable=True)
    is_active = Column(Boolean, default=True)

Configuration and Dependency Management

Your configuration (API keys, database URLs, feature flags) should never be hardcoded. It should be read from environment variables. The app/core/config.py file handles this elegantly using Pydantic’s BaseSettings.

from pydantic import BaseSettings, PostgresDsn
from typing import Optional

class Settings(BaseSettings):
    PROJECT_NAME: str = "My Awesome FastAPI App"
    PROJECT_VERSION: str = "1.0.0"

    # Database
    DATABASE_URL: Optional[PostgresDsn] = None

    # Secrets
    SECRET_KEY: str = "you-will-never-guess"  # Change this and use an env var in production!

    class Config:
        env_file = ".env"

settings = Settings()

The most common pitfall here? Forgetting to create your .env file in the project root and then wondering why DATABASE_URL is None. It happens to the best of us. The other big one is accidentally checking your .env file into version control, thereby gifting your database credentials to the entire internet. Add .env to your .gitignore file. Now. I’ll wait.

This structure might feel like overkill for “Hello World,” but trust me, by the time you add your third route and a database connection, you’ll be profoundly grateful you started this way. It’s the foundation everything else is built on, and a solid foundation is never a waste of time.