A standard FastAPI + Postgres starter using async SQLAlchemy 2.0, Alembic migrations, and uv for dependency management.
| Layer | Choice |
|---|---|
| Web framework | FastAPI (fastapi[standard]) |
| Server | Uvicorn (via fastapi dev / fastapi run) |
| ORM | SQLAlchemy 2.0 (async) |
| DB driver | asyncpg |
| Migrations | Alembic (async-aware) |
| Config | pydantic-settings (reads .env) |
| Package manager | uv |
| Tests | pytest + pytest-asyncio + httpx.AsyncClient |
| Python | 3.13+ |
fastapi-starter/
├── app/
│ ├── main.py # FastAPI() instance, mounts the API router
│ ├── core/
│ │ └── config.py # Settings (env-driven via pydantic-settings)
│ ├── api/
│ │ ├── deps.py # Shared FastAPI dependencies (DB session, ...)
│ │ └── v1/
│ │ ├── router.py # Aggregates all v1 endpoint routers
│ │ └── endpoints/
│ │ └── health.py # Sample DB-backed endpoint
│ ├── db/
│ │ └── session.py # Async engine + session factory
│ ├── models/ # SQLAlchemy ORM models
│ │ └── base.py # DeclarativeBase
│ ├── schemas/ # Pydantic request/response models
│ └── services/ # Business logic layer
├── alembic/
│ ├── env.py # Wired to app.models.Base.metadata + settings
│ ├── script.py.mako
│ └── versions/ # Migration files land here
├── tests/
│ ├── conftest.py # AsyncClient fixture
│ └── test_health.py
├── .env.example
├── alembic.ini
├── pyproject.toml
└── uv.lock
app/package — keeps imports absolute and clean (from app.core.config import settings).api/v1/— versioning is free; addv2/later without touchingv1/.models/schemas/servicessplit — DB shape, API shape, and business logic stay decoupled. They diverge sooner than you'd think.db/session.pyseparate frommodels/— engine setup is an infra concern; models are domain. Don't mix them.
- Python 3.13+
- uv (
curl -LsSf https://astral.sh/uv/install.sh | sh) - A running Postgres instance (local, Docker, or remote)
uv syncThis installs both runtime and dev dependencies (pytest, httpx, etc.).
cp .env.example .envEdit .env and set DATABASE_URL. The driver must be postgresql+asyncpg:
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi_startercreatedb fastapi_starter
# or, with psql:
psql -U postgres -c "CREATE DATABASE fastapi_starter;"The starter ships with no migrations. Once you add a model, generate the first one:
uv run alembic revision --autogenerate -m "init"
uv run alembic upgrade headuv run fastapi dev app/main.pyOpen:
- App root → http://127.0.0.1:8000
- Health check → http://127.0.0.1:8000/api/v1/health
- Swagger UI → http://127.0.0.1:8000/docs
- ReDoc → http://127.0.0.1:8000/redoc
uv run pytestpytest-asyncio is set to auto mode in pyproject.toml, so async tests don't need a decorator. Tests use httpx.AsyncClient with ASGITransport — no live server required.
Migrations are the single source of truth for your schema. Treat them as code: review, commit, and never edit applied ones.
# 1. Edit a model in app/models/
# 2. Generate a migration
uv run alembic revision --autogenerate -m "add user table"
# 3. Open alembic/versions/<hash>_add_user_table.py and REVIEW it.
# Autogenerate is not perfect — check column types, indexes, defaults.
# 4. Apply
uv run alembic upgrade head| Command | What it does |
|---|---|
alembic revision --autogenerate -m "msg" |
Diff models vs DB and write a migration |
alembic revision -m "msg" |
Empty migration (write SQL by hand) |
alembic upgrade head |
Apply all pending migrations |
alembic upgrade +1 / downgrade -1 |
Step forward/back one revision |
alembic current |
Show what's applied |
alembic history |
Full migration chain |
alembic downgrade base |
Wipe back to empty (dev only) |
- Always review the autogenerated file before applying. Alembic misses enum changes, server-side defaults, and some index renames.
- Never edit a migration after it's been applied to a shared environment. Write a new one instead.
- Fill in
downgrade(), even if you never plan to run it. It's the cheapest safety net you'll get. - Run migrations during deploy, not at app startup. Run
alembic upgrade headin CI/CD before booting the new app. - Commit
alembic/versions/to git so the migration chain stays consistent across machines.
-
Create the route module:
app/api/v1/endpoints/users.py -
Define an
APIRouter()and your routes -
Register it in
app/api/v1/router.py:from app.api.v1.endpoints import health, users api_router.include_router(users.router, prefix="/users", tags=["users"])
-
Create
app/models/user.py -
Subclass
Basefromapp.models.base -
Re-export from
app/models/__init__.pyso Alembic discovers it:from app.models.base import Base from app.models.user import User __all__ = ["Base", "User"]
-
Generate + apply a migration
Put request/response models in app/schemas/. Keep them separate from ORM models — your API shape will not stay identical to your table shape.
Put non-trivial logic in app/services/. Endpoints should stay thin: parse input → call a service → return output.
All settings live in app/core/config.py and are loaded from environment variables (with .env as a fallback).
To add a new setting:
class Settings(BaseSettings):
...
REDIS_URL: str
JWT_SECRET: str
ACCESS_TOKEN_TTL_MINUTES: int = 30Then add it to .env.example. pydantic-settings will fail loudly at startup if a required setting is missing — which is what you want.
- Absolute imports only (
from app.foo import bar), never relative. - Type hints everywhere. FastAPI uses them for validation and OpenAPI generation.
- Endpoints return Pydantic models or dicts, never raw ORM objects.
- Use
Annotated[..., Depends(...)]for dependencies (seeapp/api/deps.py). async defeverything that touches I/O (DB, HTTP, files). Syncdefis fine for pure CPU work.
The starter is dev-friendly out of the box. Before deploying:
- Replace
fastapi devwithfastapi run(oruvicorn app.main:app --workers N). - Run
alembic upgrade headas a deploy step, before new app instances boot. - Set
echo=Falseon the engine (already the default) and configure pool size to match your worker count. - Add CORS, request logging, and any middleware you need in
app/main.py. - Keep
.envout of git (already in.gitignore); use your platform's secret store in production.