FastAPI Database Integration with SQLAlchemy
Architect a robust database layer in FastAPI using SQLAlchemy 2.0.
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.
- FastAPI uses Depends() to inject a database session per request
- The get_db() generator yields a session and guarantees cleanup in finally
- Always use AsyncSession with asyncpg to avoid blocking the event loop
- Session pool size must match your worker concurrency — too many connections can starve PostgreSQL
- Biggest mistake: reusing a session across multiple handlers causes stale data and transaction leaks
Imagine a restaurant where each waiter can only serve one table at a time and must stand still while the kitchen prepares food. Async database access is like giving waiters pagers—they can take other orders and help other tables while waiting for the kitchen, dramatically increasing how many customers the restaurant can serve. FastAPI with SQLAlchemy works the same way: instead of freezing your entire application while the database thinks, it handles other requests in the gaps.
FastAPI and SQLAlchemy together form the de facto standard for building async database-backed APIs in Python. FastAPI provides native async request handling via Starlette/ASGI, while SQLAlchemy 1.4+ offers async ORM support through its asyncio extension. This combination solves the fundamental problem of blocking I/O in web applications: without async database access, your entire request pipeline stalls when querying PostgreSQL, MySQL, or SQLite. By pairing FastAPI's async endpoints with SQLAlchemy's AsyncSession, you achieve non-blocking database operations that scale to hundreds of concurrent connections without thread pool exhaustion.
Why FastAPI + SQLAlchemy Is the Standard for Async Database Access
FastAPI with SQLAlchemy is the dominant pattern for building database-backed APIs in Python because it combines FastAPI's async-first request handling with SQLAlchemy's mature ORM and async session support. The core mechanic is that each request gets its own database session, managed via a dependency that ensures the session is closed after the response is sent. This prevents connection leaks and keeps the ORM's identity map scoped to a single request, avoiding stale data across concurrent handlers.
In practice, you define a session factory using async_sessionmaker bound to an async engine (typically asyncpg for PostgreSQL). Each endpoint declares a db: AsyncSession dependency, and FastAPI's dependency injection system creates and tears down the session automatically. The key property is that SQLAlchemy's async sessions use await for all database operations, which frees the event loop to handle other requests while waiting for I/O. This gives you true concurrency without the GIL issues of synchronous ORM calls.
You reach for this stack when building any production API that needs to handle hundreds of concurrent database queries without blocking. It's especially critical for microservices where each request may fan out to multiple database reads or writes. The combination gives you the safety of an ORM with the throughput of async I/O — but only if you correctly manage session lifecycle and avoid mixing sync and async patterns.
psycopg2.OperationalError: connection already closed after the session timed out.async_sessionmaker with AsyncSession — never the sync Session in an async endpoint.await all database calls — forgetting await silently returns a coroutine object, not the query result.Async Database Configuration and Session Lifecycle
For modern FastAPI apps, we utilize asyncio. This configuration uses the postgresql+asyncpg driver. The get_db generator is the heart of the integration: it provides a session to your endpoint and guarantees closure, even if the request crashes.
Tuning SQLAlchemy Connection Pool Parameters
The connection pool is a critical lever for database performance under load. The create_async_engine function accepts several pool-related parameters that control how many connections are held open, how long to wait when no connection is free, and how to recycle stale connections. Misconfiguring these leads to AsyncpgTimeoutError or psycopg.OperationalError: connection refused. Below is a quick reference table for the most important parameters.
| Parameter | Default | Description | Production Recommendation |
|---|---|---|---|
pool_size | 5 | Maximum number of persistent connections kept in the pool | number_of_uvicorn_workers + 1 |
max_overflow | 10 | Number of temporary connections allowed beyond pool_size when pool is exhausted | 0 (prefer queueing over overloading PostgreSQL) |
pool_timeout | 30 | Seconds to wait for a connection from the pool before raising TimeoutError | 10–15 (fail fast under load) |
pool_recycle | -1 | Seconds after which a connection is recycled (closed and replaced) | 300 (5 minutes) to limit memory leaks |
pool_pre_ping | False | Execute a lightweight ping (SELECT 1) before each connection to detect stale connections | True (critical in cloud environments with load balancers) |
To apply these, pass them directly to .create_async_engine()
pool_size = number_of_uvicorn_workers + 1 to give each worker a dedicated connection plus one spare. Use max_overflow=0 so requests queue instead of creating new connections — this protects PostgreSQL from sudden spikes.pool_pre_ping=True adds a lightweight check on every connection borrow, preventing stale-connection errors. Setting pool_recycle to 300 seconds ensures connections are fresh, avoiding accumulated memory in PostgreSQL's backend processes. Monitor pg_stat_activity to see if connections are being recycled correctly.Models and Async CRUD Operations
In SQLAlchemy 2.0, we use the statement approach. This is more explicit and aligns better with static analysis tools like Mypy. Note how we keep the SQLAlchemy Model separate from the Pydantic schema.select()
select() approach is safer than session.query() because it returns a coroutine that must be awaited, so you can't accidentally forget.select() not session.query() — it forces await and works with async.db.refresh() to load generated fields.Performing Update and Delete Operations
Update and delete operations follow the same pattern as reads: get the object, modify or delete it, then commit. For updates, you can either load the object, change its attributes, and commit, or use an UPDATE statement directly. For deletes, you call await db.delete(obj) after fetching the object. Both require a 404 check when the object is not found. Remember to call after an update if you need the updated row (e.g., when a database trigger modifies a timestamp).refresh()
refresh() because we return nothing. In high-throughput systems, consider soft deletes (adding an is_active boolean) instead of hard deletes to preserve audit trails and avoid foreign-key cascades. When updating only a subset of fields, use model_dump(exclude_unset=True) to only change what the client sent, not the entire object.Repository Pattern for Data Access
The Repository pattern abstracts database operations behind an interface. Instead of calling db.execute(select(...)) directly in your endpoint, you define a repository class (e.g., UserRepository) that contains all query logic. The endpoint depends on the repository interface, not the session directly. This separation makes unit testing possible — you can mock the repository. It also keeps your business logic clean when multiple endpoints or background tasks need the same queries.
- Centralizes query logic — no SQL leaks into endpoint handlers
- Makes unit testing trivial: mock the repository, not the database
- Enables swapping storage (e.g., from PostgreSQL to MongoDB) without changing business code
- Avoids duplication when multiple consumers need the same query
Managing Migrations with Alembic
Alembic is the standard migration tool for SQLAlchemy. Install it with pip install alembic, then run alembic init alembic. Configure the env.py to use your async engine and import your Base models. Use alembic revision --autogenerate -m "description" to generate migration scripts and alembic upgrade head to apply them. For async databases, you must wrap the migration in an async context — Alembic's AsyncMigrationContext handles this.
Containerizing the Application with Docker
Docker and Docker Compose make it easy to run your FastAPI app alongside PostgreSQL in a reproducible environment. You need a Dockerfile that installs dependencies and uses uvicorn to serve the app, and a docker-compose.yml that defines the app service and a PostgreSQL service. The app service should wait for the database to be ready before starting — use a wait script or health check. Use environment variables for configuration so the same image works in development and production.
depends_on with condition service_healthy so the app does not start before PostgreSQL is ready. In production, you would typically run migrations as a separate step before starting the app: docker compose run app alembic upgrade head. Use Docker Compose with profiles to run one-off tasks without rebuilding. Mounting volumes for persistent data is critical; never use anonymous volumes in production.Testing Async Database Operations
Use pytest with async fixtures to manage a test database. Create a test client with httpx.AsyncClient and FastAPI's TestClient wrapper. For each test, start a transaction and roll it back after the test to ensure isolation. Use a dedicated test PostgreSQL database with the same schema (use Alembic to set it up). Install pytest-asyncio to support async test functions.
Handling Transactions and Rollbacks in Production
In production, every request should be treated as a unit of work. Use the async with context manager to automatically commit on success and rollback on exception. If you need partial rollback within a transaction, use savepoints via session.begin()await . Always keep transactions short — avoid network calls or long computations inside a transaction block. Set session.begin_nested()statement_timeout in PostgreSQL to prevent runaway queries from holding locks.
Why Your Dependency Injection Is Probably Wrong (And How to Fix It)
Most tutorials show you how to create a session dependency and call it a day. They skip the part where you leak connections under load. The real problem isn't creating sessions—it's managing their lifecycle when requests fail.
FastAPI's dependency injection system gives you yield for a reason. Use it. Every dependency should yield a session and handle cleanup in a get_db()finally block. If you don't, a half-dead transaction can hold a connection from the pool indefinitely.
Here's what the top-ranked pages miss: your dependency should return a Session instance, not a generator. FastAPI handles the generator internally. If you yield multiple times (thinking you're being clever), you'll get a RuntimeError in production. I've seen it bring down APIs on Black Friday.
The pattern is simple: create one dependency per request scope. Yield once. Clean up once. Trust the framework to wire it correctly. If you're sharing sessions between dependencies, you're doing it wrong. Each route gets its own session, and that session lives only as long as the request.
The Silent Killer: How Session `expire_on_commit` Wrecks Async Performance
You've written clean CRUD. Tests pass locally. Then production hits you with a 10x latency spike on reads. Nine times out of ten, it's expire_on_commit=True doing async I/O to refresh every object after every commit.
SQLAlchemy's default behavior is to expire all objects in a session after commit. In synchronous code, that's fine—the next access fetches lazily. In async code with AsyncSession, that lazy fetch becomes a coroutine you didn't await. If you access an expired attribute outside an async context, you get a MissingGreenlet exception or, worse, silent desync.
Set expire_on_commit=False when creating your sessionmaker. You don't need objects to be fresh every time—you need performance. If you must reload, do it explicitly with await session.refresh(obj) at the point you need fresh data.
Your competitor pages don't mention this because they test with SQLite and a single user. In production with PostgreSQL and concurrent requests, this setting is the difference between 50ms response times and 500ms. I learned this the hard way when a migration deployed expiring every object in a batch of 10,000 users.
MissingGreenlet exception in prod, first suspect expire_on_commit=True. Change it to False, then audit for explicit refreshes where needed. Your ORM shouldn't be a performance leak.expire_on_commit=False in async SQLAlchemy sessions. Reload only when you need definitive freshness.Read Operations with Public Schemas
Why use HeroPublic? When exposing API endpoints, you rarely want to return internal database fields like hashed_password or is_deleted. HeroPublic defines exactly which fields are safe for external consumption. For listing heroes, you need a paginated query: SELECT * FROM heroes limited by offset and limit. SQLAlchemy's combined with select() ensures you don't accidentally leak sensitive data. The async pattern uses exec_options()session.execute(select(Hero).offset(offset).limit(limit)) and maps results to HeroPublic via model validation. For a single hero, use session.get(Hero, hero_id) — it returns None or a Hero instance, which you immediately cast to HeroPublic or raise 404. This separation forces a clean boundary: internal model for DB operations, public schema for responses. Never return your ORM model directly from an API — that's how secrets get exposed. Always validate through a Pydantic schema that explicitly whitelists output fields. Your database model can change without breaking the public contract.
Hero directly from an async session, SQLAlchemy may lazy-load hashed_password even if you didn't select it. Always use model_validate() to trigger Pydantic's exclusion rules before the session closes.Update Operations with Partial Schemas
Why HeroUpdate? HTTP PATCH semantics demand partial updates — a client sends only the fields they want to change, not the entire object. HeroUpdate makes every field optional, so you can detect which keys were provided via model_dump(exclude_unset=True). This prevents accidentally setting a field to None when the client omitted it. The update pattern: fetch the existing Hero instance from the database, apply only the provided fields using or setattr(), commit, refresh, and return a fresh sqlalchemy.orm.attributes.set_attribute()HeroPublic. Never create a new Hero object and merge — that overwrites relational fields like team_id if omitted. Always work with the tracked ORM instance. The refresh step is critical in async mode because expire_on_commit=False (common in production) means the object isn't auto-refreshed. Without explicit refresh, you'll return stale data. This pattern also protects against race conditions: if two PATCH requests arrive simultaneously, only one commit succeeds; the other gets a retry or conflict response.
refresh() after commit with expire_on_commit=False returns an object with original field values. The response will lie to the client about what was saved.AsyncSession Not Closed – Connection Pool Exhaustion
Depends() — it should be fine.async_sessionmaker() directly in a background task without the context manager, so session.close() was never called.AsyncSessionLocal() as session: or ensure finally block calls await session.close().- Every session must be scoped to a unit of work — never create sessions without a guaranteed close.
- Monitor connection pool usage with pg_stat_activity.
- Set a statement_timeout and idle_in_transaction_session_timeout on PostgreSQL as a safety net.
db.commit() or db.execute(). Missing await leaves the coroutine unexecuted.session.rollback() on exception. Use logging to trace transaction boundaries.docker exec <pg_container> psql -U user -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active'"docker logs <fastapi_container> | grep 'connection'pg_terminate_backend()Key takeaways
get_db() generator is a 'Unit of Work' pattern implementation—ensuring atomic transactions per request.asyncpg or aiosqlite with FastAPI to avoid stalling the event loop on database waits.select(Model) over the legacy session.query(Model) for better type safety and future-proofing.expire_on_commit=False in your session factory to prevent 'greenlet' errors when accessing attributes after a commit in async contexts.Common mistakes to avoid
5 patternsUsing synchronous SQLAlchemy engine with FastAPI
Forgetting to await db.commit()
db.commit() after adding objects. Use a linter or MyPy with async plugin to catch missing awaits.Not setting expire_on_commit=False
db.refresh() explicitly.Using the same session across multiple requests (global session)
Not rolling back on exception
session.rollback() on any exception. The async context manager does this automatically if you use 'async with'.Interview Questions on This Topic
What is the 'N+1 Problem' in SQLAlchemy, and how do you use `joinedload` or `selectinload` in FastAPI to solve it?
selectinload(Relationship) on the query to eagerly load related objects in one additional query. joinedload works too but can cause cartesian product issues with many-to-many relationships. For performance, prefer selectinload because it issues a separate SELECT with an IN clause, which is efficient and avoids the large result set blow-up of joins.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.
That's Python Libraries. Mark it forged?
8 min read · try the examples if you haven't