Search Topics
Search across all FastAPI topics
Database Sessions
Core ConceptSessions are your window into the database. Get the lifecycle wrong, and your app works in dev but dies in production under load.
Your API works in development. In production under load, you start seeing TimeoutError: QueuePool limit reached. Database connections are leaking because sessions aren't being closed properly.
# Production under load (50 concurrent users): sqlalchemy.exc.TimeoutError: QueuePool limit of 5 overflow 10 reached, connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r) # Your 5 connection pool slots + 10 overflow = 15 connections # All 15 are stuck open, waiting for sessions that were never closed. # New requests wait 30s, then timeout.
Question
Why does it work locally but break in production? Because locally you have one user — you. In production, 50 users hit the API at once. Each request opens a database connection. If you don't close sessions properly, those connections pile up until the pool is exhausted. Then everyone waits. Then everyone times out.
How sessions flow through a request
Every request gets its own session. That session borrows a connection from the pool, does work, and then returns it. The key word is "returns." If you don't return it, the pool shrinks until it's empty.
Request arrives
GET /users
get_db() called
Session created from pool
yield session
Endpoint uses it
finally: close()
Connection returned to pool
Engine & SessionLocal: set up once, use forever
The engine manages the connection pool. SessionLocal is a factory that produces new Session instances. You create both once at startup — never inside a request handler.
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
# SQLite (development) — check_same_thread is SQLite-specific
SQLALCHEMY_DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}, # SQLite only!
)
# PostgreSQL (production) — just change the URL
# SQLALCHEMY_DATABASE_URL = "postgresql://user:pass@localhost/dbname"
# engine = create_engine(SQLALCHEMY_DATABASE_URL)
# This factory creates sessions. You call it per-request.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
passWatch out
See autoflush=False? That's intentional. With autoflush on, SQLAlchemy sends SQL to the database before every query to sync pending changes. This can cause subtle bugs where uncommitted data shows up in queries. Keep it off and commit explicitly.
Engine Setup
What you just learned
One engine per app — it owns the connection pool
SessionLocal is a factory, not a session. You call it to create sessions.
autoflush=False prevents surprise SQL before your queries
The database URL is the only thing that changes between SQLite and PostgreSQL
The get_db() pattern: your safety net
This is the canonical pattern every FastAPI + SQLAlchemy project uses. The yield keyword is what makes it safe — the finally block ensures the session is always closed, even when your endpoint throws an exception.
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
app = FastAPI()
def get_db():
db = SessionLocal() # Grab a session from the factory
try:
yield db # Hand it to the endpoint
finally:
db.close() # ALWAYS close — even if the endpoint exploded
@app.get("/users")
def list_users(db: Session = Depends(get_db)):
# db is a live session — use it freely
return db.query(User).all()
# After this returns (or raises), db.close() runs automaticallyThe missing finally: block
You write get_db() with yield but forget the try/finally wrapper. Endpoints work fine... until one raises an exception. The session never closes, and the connection leaks.
def get_db():
db = SessionLocal()
yield db
db.close() # This line NEVER runs if the endpoint raises!get_db() Pattern
What you just learned
yield in a Depends() function creates a lifecycle: before yield = setup, after yield = cleanup
try/finally is non-negotiable — without it, exceptions leak connections
Every leaked connection is one less slot in your pool. Enough leaks = dead app.
Connection Pool: tuning the numbers
SQLAlchemy maintains a pool of database connections so you don't open a new TCP connection for every request. But the defaults might not match your traffic. Here's what each setting does.
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_size=5, # Connections kept open and ready
max_overflow=10, # Extra connections allowed when pool is full
pool_timeout=30, # Seconds to wait for a connection before error
pool_recycle=1800, # Recycle connections after 30 minutes (avoid stale)
echo=False, # Set True to log ALL SQL statements (noisy but useful)
)
# pool_size=5 + max_overflow=10 = up to 15 concurrent connections
# If all 15 are busy, new requests wait up to pool_timeout seconds
# After pool_timeout, you get the TimeoutError from the hook aboveInsight
A good rule of thumb: set pool_size to the number of web workers you run. If you have 4 Gunicorn workers, a pool_size of 5 per worker gives you 20 total connections. Check your database's max_connections setting to make sure you don't exceed it.
Try It: Connection Pool Monitor
Click "New Request" to simulate requests claiming connections from the pool. See what happens when the pool is exhausted — that's the TimeoutError from the hook above.
Async Sessions: when you need them
If your endpoints use async def, you need async sessions. Using synchronous sessions in an async endpoint blocks the event loop — one slow query can freeze your entire app.
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
# Note the driver change: asyncpg for PostgreSQL, aiosqlite for SQLite
ASYNC_DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/dbname"
async_engine = create_async_engine(ASYNC_DATABASE_URL)
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession)
# Same pattern, but async — and "async with" handles cleanup automatically
async def get_db():
async with AsyncSessionLocal() as session:
yield session
@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User))
return result.scalars().all()Async Sessions
What you just learned
async def endpoints need async sessions — sync sessions block the event loop
async with handles cleanup automatically (no manual try/finally needed)
The driver changes: asyncpg for PostgreSQL, aiosqlite for SQLite
Everything else is await — db.execute(), not db.query()
Think about it...
If your get_db dependency uses yield but you forget the finally: db.close(), what happens when an endpoint raises an exception?
Hint: Think about what happens to code after yield when the generator is abandoned.
Key Points
Engine
One engine per app — creates and manages the connection pool
SessionLocal
Factory that produces new Session instances bound to the engine
Yield Dependency
get_db() with yield ensures sessions are always closed, even on errors
Pool Tuning
Configure pool_size and max_overflow to match your concurrency needs