Project Structure
Table of Contents
How to lay out a FastAPI project so it stays maintainable as it grows.
Directory Layout
Small projects (flat)
For a handful of endpoints with no database, a flat layout is fine:
my-api/
├── src/
│ ├── __init__.py
│ ├── app.py # create_app factory, lifespan
│ ├── main.py # entry point: app = create_app()
│ ├── config.py # Settings (pydantic-settings)
│ ├── dependencies.py # Depends helpers + type aliases
│ ├── schemas.py # Pydantic request/response models
│ └── routes.py # all route handlers
├── tests/
│ ├── __init__.py
│ └── test_routes.py
├── pyproject.toml
├── .env
└── .env.exampleProduction projects (layered)
Once you have multiple resources, background tasks, or a database, split by layer:
my-api/
├── src/
│ ├── __init__.py
│ ├── app.py # create_app factory, lifespan
│ ├── main.py # entry point: app = create_app()
│ ├── config.py # Settings
│ ├── dependencies.py # shared Depends (settings, db session)
│ ├── exceptions.py # custom exception classes
│ ├── middleware.py # CORS, request-id, logging middleware (optional?)
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── users.py # UserCreate, UserResponse, ...
│ │ └── bookings.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── users.py # SQLAlchemy / ORM models
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── users.py # router = APIRouter(prefix="/users")
│ │ └── bookings.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── users.py # business logic, orchestration
│ └── repositories/
│ ├── __init__.py
│ └── users.py # raw DB queries
├── tests/
│ ├── __init__.py
│ ├── conftest.py # shared fixtures (app, client, db)
│ ├── unit/
│ │ ├── __init__.py
│ │ ├── test_schemas.py # pure validation, no I/O
│ │ └── test_services.py # business logic with mocked repos
│ └── integration/
│ ├── __init__.py
│ ├── conftest.py # real DB, test containers
│ ├── test_users.py # full request → response
│ └── test_bookings.py
├── pyproject.toml
├── .env
└── DockerfileWhat goes where:
| Layer | Responsibility |
|---|---|
routers/ | HTTP concerns only: parse request, call service, return response |
services/ | Business logic, orchestration, validation that depends on state |
repositories/ | Data access — SQL queries, external API calls |
schemas/ | Pydantic models for request/response (no DB coupling) |
models/ | ORM models (SQLAlchemy, etc.) |
dependencies.py | Depends helpers and Annotated type aliases |
exceptions.py | Custom exception classes (see Error Handling) |
tests/unit/ | Fast, no I/O — schema validation, service logic with mocked deps |
tests/integration/ | Slower, real DB or test containers — full request/response cycles |
Note
Some teams prefer domain-driven grouping (everything for
usersin one folder). Both work — the layered approach shown here is easier to follow when the team is small and the domain boundaries are still fuzzy.
pyproject.toml with uv
[project]
name = "my-api"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]", # includes uvicorn + the fastapi CLI
"pydantic-settings",
"sqlalchemy[asyncio]",
]
[dependency-groups]
dev = [
"ruff",
"pre-commit",
]
test = [
"pytest",
"pytest-asyncio",
"httpx", # for AsyncClient in tests
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py313"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]Install everything with:
uv sync --all-groupsNote
fastapi[standard]pulls inuvicorn[standard](withuvloop+httptools), thefastapiCLI, and other production essentials. Always use this extra unless you have a reason to pin a specificuvicornversion.
Running the App
The fastapi CLI (bundled with fastapi[standard]) provides two commands:
# Development: auto-reload on file changes, debug mode
uv run fastapi dev
# Production-like: no reload, single worker
uv run fastapi runAuto-discovery: the CLI looks for a variable named app in main.py or app.py (in that order). If your app lives elsewhere, pass the path explicitly:
uv run fastapi dev src/main.py| Command | Reload | Debug | Use case |
|---|---|---|---|
uv run fastapi dev | yes | yes | Local development |
uv run fastapi run | no | no | Containers, staging, CI |
uv run uvicorn src.main:app | manual flags | manual flags | Full control over workers, host, port |
For production deployments behind a reverse proxy, you’ll typically use uvicorn directly or a process manager like gunicorn with uvicorn workers — see the Deployment page.
The Entry Point
For anything beyond a toy project, use a create_app() factory. It keeps initialization explicit and makes testing straightforward (each test can get a fresh app).
Keep the factory in src/app.py — separate from src/main.py — so that tests can from src.app import create_app without triggering the module-level app = create_app() as a side effect.
# src/app.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.dependencies import get_settings
from src.exception_handlers import register_exception_handlers
from src.routers import bookings, users
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
# set up DB pools, caches, etc. on app.state
# (see the Dependency Injection page for the full pattern)
yield
# teardown
def create_app() -> FastAPI:
app = FastAPI(title="My API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # ⚠ dev only — see warning below
allow_methods=["*"],
allow_headers=["*"],
)
register_exception_handlers(app)
app.include_router(users.router)
app.include_router(bookings.router)
return app# src/main.py
from src.app import create_app
app = create_app()main.py is the file the FastAPI CLI discovers (fastapi dev looks for an app variable in main.py). Tests import create_app from src.app and never touch main.py.
Warning
allow_origins=["*"]is fine for local development but must not be used in production — it allows any website to make credentialed requests to your API. In production, list your specific origins (e.g.["https://app.example.com"]). See the FastAPI CORS docs.
Warning
Don’t put heavy initialization (DB connections, HTTP clients) inside
create_app()directly — use the lifespan context manager instead.create_app()should be fast and side-effect-free so tests can call it freely. See Dependency Injection for the lifespan pattern.
A typical router file looks like this:
# src/routers/users.py
from fastapi import APIRouter
from src.dependencies import DbSession, ServiceDep
from src.schemas.users import UserCreate, UserResponse
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", status_code=201)
async def create_user(body: UserCreate, db: DbSession) -> UserResponse:
...
@router.get("/")
async def get_users(db: DbSession) -> List[UserResponse]:
...
@router.get("/{user_id}")
async def get_user(user_id: int, db: DbSession) -> UserResponse:
...Configuration
See the Configuration page for the full Settings class setup: .env loading, nested settings with prefixes, and environment-specific overrides. For how to inject Settings into route handlers via Depends, see Dependency Injection.