How to Organize a FastAPI Project: Structure Tips and Real‑World Examples
Why a Thoughtful Layout Matters
FastAPI may feel lightweight, but a messy folder tree quickly turns a promising API into a debugging nightmare. A clear structure keeps routes readable, dependencies injectable, and tests isolated—especially when the codebase outgrows a single main.py.
Core Building Blocks
Before diving into directories, it helps to label the main components you’ll encounter in almost any FastAPI service:
- Routers: groups of endpoints, usually per domain (e.g., users, items).
- Schemas: Pydantic models that validate request and response bodies.
- Services: business logic that lives outside the route handlers.
- Dependencies: reusable functions for DB sessions, auth, etc.
- Tests: unit and integration checks that mirror your package layout.
Suggested Directory Layout
Below is a flexible skeleton that scales from a hobby project to a production‑grade microservice:
my_fastapi_app/├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── v1/
│ │ │ ├── __init__.py
│ │ │ ├── users.py
│ │ │ └── items.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── security.py
│ ├── crud/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── item.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── item.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── item.py
│ └── db/
│ ├── __init__.py
│ └── session.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_users.py
│ └── test_items.py
└── pyproject.toml
What Each Folder Does
- app/api: versioned routers keep backward compatibility painless.
- app/core: global settings, secret handling, and reusable utilities.
- app/crud: thin wrappers around ORM queries; separates DB concerns from routes.
- app/models: SQLAlchemy (or Tortoise) classes that map to tables.
- app/schemas: Pydantic models that define what’s accepted and returned.
- app/db: session factory and engine creation, often driven by
core.config.
Putting It All Together: A Minimal Example
Imagine you need a simple endpoint to create a user. The files interact like this:
app/schemas/user.py
from pydantic import BaseModel, EmailStrclass UserCreate(BaseModel):
email: EmailStr
password: str
class UserRead(BaseModel):
id: int
email: EmailStr
class Config:
orm_mode = True
app/crud/user.py
from sqlalchemy.orm import Sessionfrom ..models.user import User
from ..schemas.user import UserCreate
def create_user(db: Session, payload: UserCreate) -> User:
db_user = User(email=payload.email, hashed_password=hash(payload.password))
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
app/api/v1/users.py
from fastapi import APIRouter, Depends, HTTPException, statusfrom sqlalchemy.orm import Session
from ...crud.user import create_user
from ...schemas.user import UserCreate, UserRead
from ...db.session import get_db
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register_user(payload: UserCreate, db: Session = Depends(get_db)):
try:
return create_user(db, payload)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
app/main.py
from fastapi import FastAPIfrom .api.v1 import users
app = FastAPI(title="My Awesome API")
app.include_router(users.router, prefix="/api/v1")
This tiny flow demonstrates the separation of concerns: validation lives in schemas, persistence in crud, and routing stays thin.
Testing Tips Aligned with the Structure
Because tests mirror the package layout, you can quickly locate the target of a failing case. A common pattern:
- Use
pytest.fixtureintests/conftest.pyto spin up a temporary DB. - Import the FastAPI app from
app.mainand run requests withhttpx.AsyncClient. - Validate both status codes and response schemas.
Scaling Beyond the Basics
When the service grows, consider these optional upgrades without breaking the core layout:
- Background tasks: a
tasks/module that houses Celery or RQ workers. - Versioned documentation: separate OpenAPI specs per API version.
- Plugin architecture: dynamically load routers from a
plugins/folder.
Final Thoughts on Maintaining Order
Good folder hygiene isn’t a one‑time chore; it’s a habit. Whenever you add a new domain, ask yourself:
- Does it belong in an existing versioned router or need its own?
- Should the data model sit alongside similar tables, or merit a dedicated subpackage?
- Is there a reusable dependency I can extract for future endpoints?
Answering these questions early keeps the codebase approachable, even as contributors come and go.