Search Topics

Search across all FastAPI topics

GitHub

Database Sessions

Core Concept

Sessions 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.

terminal
# 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.

database.py
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):
    pass

Watch 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.

database.py
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 automatically

The 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.

Broken code
database.py
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.

database.py
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 above

Insight

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.

database.py
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