A reference-grade, fully asynchronous Books REST API built with FastAPI, SQLAlchemy 2, and asyncpg.
Quickstart · API Reference · Architecture · Configuration · Testing · Benchmark vs Flask · Roadmap
Mirror project:
bilouro/FlaskProject— same domain, same contract, sync edition. Use the benchmark harness in this repo to compare both side by side under controlled load.
Books API is a small, opinionated CRUD service built as a teaching reference for a modern Python web stack. Every choice is intentional: async end-to-end, strict typing at every boundary, a clean separation of HTTP / domain / data layers, and a test suite that runs in under a second with 100 % coverage and no external dependencies.
It started life as the FlaskProject twin — same domain, same contract — but rebuilt around FastAPI's async-first model and the latest stable releases of SQLAlchemy, Pydantic, and Alembic.
- Show what an idiomatic, production-shaped FastAPI project looks like in 2026.
- Demonstrate async SQLAlchemy 2 + asyncpg with real migrations, not a toy SQLite glued together with
Base.metadata.create_all. - Provide a copy-pasteable foundation for new services: lifespan, config, logging, error envelope, tests — all wired up.
- Fully async request / response pipeline (FastAPI → repository → asyncpg).
- Strict Pydantic v2 schemas with
extra="forbid"and typed validation. - A repository pattern that keeps HTTP concerns out of SQL.
- One unified error envelope across
4xx/5xx, including structured validation details. - Structured JSON logging ready for any log shipper.
- A test suite that runs in < 1 s on an in-memory database — 100 % coverage with zero mocks of your own code.
- A multi-stage
Dockerfileand adocker-compose.ymlthat brings up Postgres and the API together.
- Overview
- Quickstart
- Architecture
- Project Layout
- API Reference
- Error Envelope
- Configuration
- Database & Migrations
- Testing
- Observability
- Performance & Concurrency
- Benchmark vs Flask
- Roadmap
- Contributing
- Security
- FAQ
- License
- Acknowledgments
git clone https://github.com/bilouro/FastAPIProject.git
cd FastAPIProject
docker compose up --buildWait a few seconds, then:
curl http://localhost:8000/health
# {"status":"ok","database":"ok","version":"1.0.0"}Open Swagger UI: http://localhost:8000/docs
git clone https://github.com/bilouro/FastAPIProject.git
cd FastAPIProject
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then edit APP_DB_PASSWORD etc.
alembic upgrade head
uvicorn app.main:app --reload --port 8000That's it. Visit:
| URL | What it serves |
|---|---|
| http://localhost:8000 | Service index (JSON) |
| http://localhost:8000/v1/books | Books resource (paginated list) |
| http://localhost:8000/health | Liveness + DB readiness probe |
| http://localhost:8000/docs | Swagger UI |
| http://localhost:8000/redoc | ReDoc |
| http://localhost:8000/openapi.json | OpenAPI 3 schema |
| http://localhost:8000/swagger.json | Same schema, Flask-compat alias |
┌──────────────────────────────────────────┐
│ ASGI server (uvicorn) │
└────────────────────┬─────────────────────┘
│
┌────────────────────▼─────────────────────┐
│ FastAPI app │
│ · CORS · error handlers · lifespan │
└────────────────────┬─────────────────────┘
│
┌───────────────────────────────┼───────────────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────────▼────────────┐ ┌────────▼────────┐
│ /v1/books │ │ /health · /docs │ │ /swagger.json │
│ APIRouter │ │ · /redoc · / │ │ (alias) │
└───────┬────────┘ └─────────────────────────┘ └─────────────────┘
│
┌───────▼─────────────────────────────────────────────────────┐
│ Pydantic v2 schemas (BookCreate / BookReplace / BookPatch) │
│ · extra="forbid" · strict=True · model_validator │
└───────┬─────────────────────────────────────────────────────┘
│
┌───────▼─────────────────────────────────────────────────────┐
│ BookRepository (async, typed errors) │
│ list_all · get · create · replace · patch · delete │
└───────┬─────────────────────────────────────────────────────┘
│
┌───────▼─────────────────────────────────────────────────────┐
│ SQLAlchemy 2 AsyncSession ──► asyncpg ──► PostgreSQL 16 │
└─────────────────────────────────────────────────────────────┘
- Uvicorn terminates HTTP and hands the ASGI scope to FastAPI.
- CORS middleware (optional) runs first.
- The router resolves the path → dependencies → handler.
- The
get_sessiondependency yields anAsyncSessionfrom the globalasync_sessionmaker. It commits on success or rolls back on exception, then returns the connection to the pool. - The handler delegates to
BookRepository, which speaks SQLAlchemy 2 async toasyncpg. - Domain exceptions (
BookNotFoundError,DuplicateISBNError) and validation errors are converted to a unified JSON envelope by the registered exception handlers.
- Routers never touch SQL. They orchestrate Pydantic ↔ repository.
- Repositories never raise framework exceptions. They raise
DomainErrorsubclasses. - Schemas are the only place data shapes are declared. ORM models stay private to the persistence layer.
- Settings are read once at startup; cached via
lru_cache.
.
├── app/
│ ├── __init__.py # package metadata
│ ├── main.py # FastAPI factory, lifespan, /health, root
│ ├── config.py # pydantic-settings, env loading, DSN
│ ├── database.py # async engine, sessionmaker, get_session
│ ├── exceptions.py # DomainError + JSON envelope handlers
│ ├── logging_config.py # structured JSON logging
│ └── books/
│ ├── models.py # SQLAlchemy 2.x Book ORM model
│ ├── schemas.py # Pydantic v2 request / response models
│ ├── repository.py # async CRUD with typed errors
│ └── router.py # APIRouter mounted under /v1
├── alembic/
│ ├── env.py # async Alembic environment
│ ├── script.py.mako
│ └── versions/
│ └── 0001_initial.py
├── tests/ # 71 tests, 100 % coverage on app/
│ ├── conftest.py # AsyncClient + in-memory SQLite
│ ├── test_config.py · test_database.py · test_schemas.py
│ ├── test_repository.py · test_router.py · test_main.py
│ └── test_lifespan.py · test_logging_config.py
├── alembic.ini
├── Dockerfile # multi-stage, non-root, healthcheck
├── docker-compose.yml # Postgres 16 + API
├── dbfixtures.sql
├── pyproject.toml # pytest, coverage, ruff
├── requirements.txt
├── .env.example
└── LICENSE
All resource endpoints live under the /v1 prefix. Cross-cutting endpoints (/health, /docs, ...) stay at the root, by convention.
| Method | Path | Body | Success | Failure |
|---|---|---|---|---|
GET |
/health |
— | 200 |
always 200; DB state in database field |
GET |
/v1/books |
— | 200 |
— |
GET |
/v1/books/{id} |
— | 200 |
404 |
POST |
/v1/books |
BookCreate |
201 |
422 (validation), 409 (duplicate ISBN) |
PUT |
/v1/books/{id} |
BookReplace |
200 |
404, 422, 409 |
PATCH |
/v1/books/{id} |
BookPatch |
200 |
404, 422 (no fields / unknown field) |
DELETE |
/v1/books/{id} |
— | 204 |
404 |
# Create
curl -X POST http://localhost:8000/v1/books \
-H 'content-type: application/json' \
-d '{"title":"1984","author":"George Orwell","year":1949,"isbn":"978-0451524935"}'
# List
curl http://localhost:8000/v1/books
# Partial update
curl -X PATCH http://localhost:8000/v1/books/1 \
-H 'content-type: application/json' \
-d '{"status":"archived"}'
# Replace
curl -X PUT http://localhost:8000/v1/books/1 \
-H 'content-type: application/json' \
-d '{"title":"1984","author":"George Orwell","year":1949,"isbn":"978-0451524935"}'
# Delete
curl -X DELETE http://localhost:8000/v1/books/1 -iEvery non-2xx response shares a single shape, inspired by RFC 9457 (Problem Details for HTTP APIs):
{
"error": "Not Found",
"message": "Book not found",
"code": 404,
"path": "/v1/books/999"
}Validation failures (HTTP 422) include a details list matching RequestValidationError.errors():
{
"error": "Unprocessable Content",
"message": "Request validation failed",
"code": 422,
"path": "/v1/books",
"details": [
{ "type": "missing", "loc": ["body", "isbn"], "msg": "Field required", "input": {} }
]
}The same envelope is produced for 404 (unknown route), 405, 409 (duplicate ISBN), and unhandled 500 errors — clients only ever parse one shape.
All settings come from environment variables (prefixed APP_) and/or a .env file. See .env.example.
| Variable | Default | Purpose |
|---|---|---|
APP_ENV |
dev |
One of dev · test · prod |
APP_LOG_LEVEL |
INFO |
Stdlib log level |
APP_CORS_ORIGINS |
[] |
JSON array of allowed origins |
APP_DB_HOST |
127.0.0.1 |
Postgres host |
APP_DB_PORT |
5432 |
Postgres port |
APP_DB_NAME |
app_db |
Postgres database |
APP_DB_USER |
app_user |
Postgres user |
APP_DB_PASSWORD |
changeme |
Postgres password |
APP_DATABASE_URL |
(unset) | Full override DSN (skips per-var assembly) |
The DSN is computed as:
postgresql+asyncpg://<user>:<password>@<host>:<port>/<name>
Unless APP_DATABASE_URL is set, in which case that value wins.
# Apply all migrations
alembic upgrade head
# Create a new revision after model changes
alembic revision --autogenerate -m "describe change"
# Roll back the most recent migration
alembic downgrade -1
# Seed sample data
psql -h "$APP_DB_HOST" -U "$APP_DB_USER" -d "$APP_DB_NAME" -f dbfixtures.sqlThe Alembic environment is configured to run the engine in async mode, reusing the DSN computed by app.config.Settings. There is no separate sync driver to install.
pytest # full suite + 100 % coverage gate
pytest tests/test_router.py -v # one module
pytest -k duplicate # by keyword
pytest --cov-report=html # generate htmlcov/index.html- 71 tests spanning config, database, schemas, repository, router, exception handlers, lifespan, and logging.
- 100 % branch & line coverage on
app/*, enforced by--cov-fail-under=100inpyproject.toml. - No external services needed. Tests swap the FastAPI
get_sessiondependency for an in-memoryaiosqliteengine. - httpx + ASGITransport drives the app in-process — fast, deterministic.
pytest-asyncioauto mode removes the boilerplate of marking every coroutine.
$ pytest -q
....................................................................... [100%]
TOTAL 302 0 26 0 100%
Required test coverage of 100% reached. Total coverage: 100.00%
71 passed in 0.54s
app.logging_config.configure_logging() installs a JSON formatter on stdout for all loggers, including uvicorn's:
{"asctime": "2026-05-12 22:58:00,000", "levelname": "INFO", "name": "app.main", "message": "starting app env=prod version=1.0.0"}Drop this straight into Loki, CloudWatch, Datadog, or any log shipper that understands JSON lines — no parsers, no regex.
The sqlalchemy.engine logger is pinned at WARNING by default so query noise stays out of production logs. Bump APP_LOG_LEVEL=DEBUG in dev to see them.
- Async everything. A single Uvicorn worker can handle thousands of concurrent in-flight requests, bounded by your Postgres pool size.
- Connection pooling via SQLAlchemy's default async pool, with
pool_pre_ping=Trueto recover from dropped connections. - One engine per process, created lazily, disposed on lifespan shutdown.
- No N+1 risk in this domain — every endpoint touches at most one row by primary key or a single
SELECT *. Ordered byidfor stable pagination later. - Production-ready Dockerfile runs as non-root, includes a
HEALTHCHECK, and uses Python 3.12-slim for a small surface area.
For scale-out, run multiple Uvicorn workers behind a reverse proxy:
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4This repo ships a side-by-side load benchmark comparing this FastAPI service against its Flask twin under controlled identical conditions: same Postgres, same schema, same Docker host, equal CPU budgets.
Headline finding from the I/O-fanout workload (the canonical async use case — every request triggers pg_sleep(50ms) server-side):
| Metric | Flask (gunicorn 4w/4t) | FastAPI (uvicorn 4w) |
|---|---|---|
| Throughput (req/s) | 265 | 992 |
| Client p50 (ms) | 905 | 226 |
| Client p95 (ms) | 2,615 | 733 |
→ FastAPI ≈ 3.7× throughput at ~1/4 the client p50 on this workload — exactly where async is supposed to shine, and does.
On read-light and mixed CRUD workloads the gap shrinks to 7-14%. Full results, every percentile, and per-workload analysis are in benchmark/RESULTS.md.
Prereqs: Docker, k6 (e.g. brew install k6), Python with psycopg2-binary (pip install psycopg2-binary), and the FlaskProject repo cloned next to this one so docker-compose can build both contexts.
# 1. Bring up Postgres + both APIs (this side runs alembic; Flask reuses the same schema)
docker compose -f benchmark/docker-compose-bench.yml up --build -d
# 2. Wait until both health endpoints return 200
curl -fsS http://localhost:5001/health && echo " flask ok"
curl -fsS http://localhost:8000/health && echo " fastapi ok"
# 3. Seed 10,000 books into Postgres (shared by both APIs)
python benchmark/seed.py --count 10000 --reset
# 4. Run the full sweep: 3 workloads × 2 APIs × 3 runs = 18 k6 invocations (~26 min)
bash benchmark/run.sh
# 5. Aggregate the 18 raw k6 JSONs into a CSV + markdown table
python benchmark/results/aggregate.py
# 6. Tear everything down
docker compose -f benchmark/docker-compose-bench.yml down -v| Script | Endpoint | Shape | Purpose |
|---|---|---|---|
benchmark/k6/read.js |
GET /v1/books/{random_id} |
ramp 50 → 1000 VUs, 90 s | Read-light, latency-bound |
benchmark/k6/mixed.js |
70 % GET / 25 % POST / 5 % PATCH | ramp 100 → 500 VUs, 80 s | Realistic CRUD mix |
benchmark/k6/fanout.js |
GET /v1/sleep?ms=50 |
ramp 50 → 500 VUs, 80 s — the async-vs-sync stress test | I/O fanout (slow upstream) |
Every script reads the server's X-Response-Time header into a custom server_time_ms k6 trend so the final report distinguishes client wall-clock from handler-only time.
The reference results were taken on an Apple Silicon MacBook, each container capped at 2 CPUs / 1 GB RAM via deploy.resources.limits. Treat the numbers as a relative comparison under controlled identical conditions, not absolute production capacity.
- Async SQLAlchemy 2 + asyncpg
- Strict Pydantic v2 schemas
- Repository pattern with typed domain errors
- Unified error envelope (RFC 9457-inspired)
- Structured JSON logging
- 100 % test coverage on
app/* - Multi-stage Dockerfile + docker-compose
- API versioning under
/v1 - Side-by-side benchmark harness vs Flask
- GitHub Actions CI (pytest + ruff + mypy)
- Pagination (
limit/offset, then keyset) - Filtering and full-text search on title / author
- Authentication (OAuth2 / JWT bearer)
- Rate limiting middleware
- OpenTelemetry traces & metrics
- Pre-commit hooks (ruff format + ruff check + mypy)
- Helm chart for Kubernetes
Issues and PRs are welcome.
- Fork the repo and create a feature branch from
main. - Run
pytestand ensure coverage stays at 100 %. - Run
ruff check .andruff format .before pushing. - Open a PR with a clear description and screenshots / curl examples for behavioural changes.
For larger refactors, please open an issue first so we can align on scope.
Found a security issue? Please do not open a public issue. Email the maintainer (see git log for contact) with details and a reproduction. We'll respond within a few business days.
The container image runs as a non-root user (appuser, UID 1000). Secrets are never read at import time — only inside Settings(), which is instantiated lazily.
Why not Django / Litestar / Quart? Personal preference and ecosystem familiarity. FastAPI hits a sweet spot between speed of development and runtime performance. The patterns here translate cleanly to any of those frameworks.
Why SQLAlchemy when SQLModel exists? SQLModel is great for prototypes but conflates the ORM and the API schema. Keeping them separate (SQLAlchemy + Pydantic) costs ~20 lines and is much easier to evolve when the public contract and the database shape diverge.
Why aiosqlite in tests instead of testcontainers / pg_tap?
Suite speed and friction. The tests run in < 1 s and need nothing but Python. For an integration smoke against real Postgres, point APP_DATABASE_URL at it and start uvicorn — that's the deployment path you're going to use anyway.
Why pytest --cov-fail-under=100?
Because "97 %" never improves. The gate forces you to either delete dead code or write the missing test before merging. It also flushes out subtle bugs (the at_least_one_field validator was added because the 100 % gate showed the patch-empty-body path was uncovered).
Why are some endpoints written as return X.model_validate(await ...)?
Coverage tracing on CPython 3.13 has a known quirk with the line right after await in an async coroutine. Putting the await and the return on the same line keeps coverage honest without sprinkling # pragma: no cover everywhere.
BSD 2-Clause © Victor H. Bilouro.
Built on the shoulders of:
- FastAPI by Sebastián Ramírez
- SQLAlchemy by Mike Bayer and contributors
- Pydantic by Samuel Colvin and contributors
- asyncpg by MagicStack
- Alembic by Mike Bayer
- httpx by Tom Christie
- The team behind PostgreSQL
{ "id": 42, // int, server-assigned "title": "1984", // string, required, 1..255 "author": "George Orwell", // string, required, 1..255 "year": 1949, // int, required, -3000..9999 "isbn": "978-0451524935", // string, required, 1..32, unique "status": "active", // string, default "active" "created_at": "2026-05-12T22:58:00Z", // server-managed "updated_at": "2026-05-12T22:58:00Z" // server-managed }