◐ ai-teacher

🐍 Backend with Python · Chapter 6

Talking to a database: SQLAlchemy and connection pools

Opening a database connection costs a network handshake, TLS and an auth exchange — often more than the query itself — so a service that connects per request spends its time saying hello.

Study this lesson free in the app →

The basics

SQLAlchemy is Python's standard way to talk to relational databases: an Engine that owns a pool of open connections, a Session that groups one request's work into a transaction, and typed models (Mapped[int]) that map rows to objects.

The problem it solves: connecting per request is slow and Postgres caps total connections (100 by default), so a service must reuse a few connections safely and keep each request's writes all-or-nothing.

The mental model: a chai stall with 15 steel tumblers. Pour, serve, wash, reuse — nobody buys a new glass per customer. When every tumbler is out, the next customer waits; if someone walks off with one, the stall runs dry by evening.

Words you must know 5

Engine
one per process, made at startup. Holds the pool and the dialect (Postgres, SQLite).
Connection pool
reusable open connections. QueuePool defaults: pool_size=5, max_overflow=10, pool_timeout=30 seconds.
Session
a unit of work: tracks your objects, sends SQL, then commits or rolls back. One per request, never shared.
Transaction
writes that land together or not at all.
N+1
one query for a list, then one more per row for a lazy relationship.

Explained

Tumblers at the chai stall

A chai stall outside Chennai Central keeps 5 tumblers on the counter. At 8 AM rush, the owner pulls 10 more from a crate under the table. After rush, he puts the extras away.

A customer takes a tumbler, drinks, hands it back; it's rinsed and refilled for the next person. If all 15 are out, the next customer stands and waits — and after half a minute, gives up and walks off.

Two ways the stall dies:

The tumblers are connections. The crate is overflow. The dishwasher is Postgres.

Three layers, each with one job

Engine      create_engine(url)        one per process; owns the pool
  └ Pool    QueuePool                 5 kept open, +10 overflow, wait 30 s
     └ Connection                     a real socket to the database
Session     Session(engine)           your unit of work for one request

Defaults in the installed SQLAlchemy 2.1, checked: pool_size=5, max_overflow=10, pool_timeout=30.0, pool_recycle=-1 (never), pool_pre_ping=False.

A model, a write, a query — and the SQL it sends

from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase): pass

class Ride(Base):
    __tablename__ = "rides"
    id: Mapped[int] = mapped_column(primary_key=True)
    pickup: Mapped[str] = mapped_column(String(80))
    fare: Mapped[int]

engine = create_engine("sqlite://", echo=True)
Base.metadata.create_all(engine)
with Session(engine) as session:
    session.add(Ride(pickup="HSR", fare=240))
    session.commit()
    ride = session.scalars(select(Ride).where(Ride.fare > 200)).first()

echo=True prints what really went over the wire (trimmed):

CREATE TABLE rides (id INTEGER NOT NULL, pickup VARCHAR(80) NOT NULL,
                    fare INTEGER NOT NULL, PRIMARY KEY (id))
BEGIN (implicit)
INSERT INTO rides (pickup, fare) VALUES (?, ?)   ('HSR', 240)
COMMIT
BEGIN (implicit)
SELECT rides.id, rides.pickup, rides.fare FROM rides WHERE rides.fare > ?   (200,)
ROLLBACK

Note the last line: leaving the with block without committing rolls back. Mapped[int] with no | None became NOT NULL — the type hint is the schema.

A payment that fails halfway

Run for real: after pay(24000) the balances were {'rider': 26000, 'driver': 24000}, and after the failing pay(10000) they were still exactly that. Without s.begin() you'd need explicit commit()/rollback() — and someone will forget one.

One Session per request in FastAPI

from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

engine = create_engine("sqlite:///rides.db", pool_pre_ping=True)  # once

def get_db():
    with Session(engine) as session:
        yield session            # closed after the response, even on error

DB = Annotated[Session, Depends(get_db)]
app = FastAPI()

@app.post("/rides", status_code=201)
def create_ride(pickup: str, fare: int, db: DB):
    ride = Ride(pickup=pickup, fare=fare)
    db.add(ride)
    db.commit()
    return {"id": ride.id, "pickup": ride.pickup, "fare": ride.fare}

@app.get("/rides/{ride_id}")
def get_ride(ride_id: int, db: DB):
    ride = db.get(Ride, ride_id)
    if ride is None:
        raise HTTPException(404, "ride not found")
    return {"id": ride.id, "pickup": ride.pickup, "fare": ride.fare}

Via TestClient: {'id': 1, 'pickup': 'HSR', 'fare': 240}, then 200, then 404 for id 9 — and afterwards pool.status() showed Current Checked out connections: 0. Every borrowed connection came back.

The yield dependency is the whole trick: FastAPI runs the code after yield when the request finishes, so the session always closes. be-009 swaps this for AsyncSession; the shape stays the same.

The ride that vanished after commit

def book(fare):
    with Session(engine) as session:
        ride = Ride(fare=fare)
        session.add(ride)
        session.commit()
    return ride

ride = book(240)
print(ride.fare)

Pool maths across a fleet

Each process has its own pool. So the real ceiling is:

pods × workers per pod × (pool_size + max_overflow)
  4  ×        3        × (   5     +     10      ) = 180

Postgres ships with max_connections = 100. At peak, 180 > 100: new connections are refused and the service fails in a way no single pod's metrics explain. Autoscaling to 8 pods doubles the problem.

The opposite failure is a pool too small for the work: requests queue for a connection, and after pool_timeout you get TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00 — every endpoint takes exactly 30 s, then fails.

Size it backwards: reserve ~10 for migrations, admin and cron; split the rest. (100 − 10) // (4 × 3) = 7 per process → e.g. pool_size=5, max_overflow=2, worst case 84.

Past a few hundred processes, put PgBouncer in front: thousands of app connections share a few dozen real ones. Also set pool_pre_ping=True (one cheap check per checkout, survives DB restarts) and pool_recycle below any firewall/proxy idle timeout so you never get handed a socket that was silently cut.

Five ways to run out of connections or speed

Words to know

EngineSQLAlchemy's process-wide entry point: database URL, dialect and the connection pool.
QueuePoolThe default pool for server databases: keeps pool_size connections, allows max_overflow extra, waits pool_timeout for a free one.
SessionA unit of work that tracks objects, sends SQL and commits or rolls back one transaction.
Identity mapWithin one Session, each row is one Python object; loading id 42 twice returns the same instance.
expire_on_commitDefault True: after commit, loaded attributes are reloaded on next access, which needs a live session.
N+1 queryOne query for a list plus one per row for a lazily loaded relationship.
pool_pre_pingTest each connection on checkout and replace it if the database dropped it.

In two minutes

Test yourself

1. What are SQLAlchemy's QueuePool defaults?

  1. pool_size=10, max_overflow=0, timeout 60 s
  2. pool_size=5, max_overflow=10, pool_timeout=30 s
  3. pool_size=1, unlimited overflow, no timeout
  4. pool_size=20, max_overflow=20, timeout 5 s
Show the answer

pool_size=5, max_overflow=10, pool_timeout=30 s — Checked in the installed 2.1: 5 kept connections, up to 10 extra under load, and 30 seconds of waiting before TimeoutError.

2. Why should you create one Engine per process instead of one per request?

  1. Engines are not thread-safe
  2. SQLAlchemy raises if you create two Engines
  3. It makes queries return faster rows
  4. The Engine owns the pool; a new one per request means a new connection per request
Show the answer

The Engine owns the pool; a new one per request means a new connection per request — The pool lives inside the Engine. Throw it away per request and every request pays connect + TLS + auth. Engines are thread-safe and meant to be shared.

3. Why does pool_pre_ping=True help after the database restarts overnight?

  1. It tests each connection on checkout and silently replaces dead ones
  2. It makes the restart faster
  3. It retries failed queries three times
  4. It keeps the database from restarting
Show the answer

It tests each connection on checkout and silently replaces dead ones — After a restart, every pooled socket is dead. Without pre-ping, the first request on each one fails. Pre-ping costs one tiny round trip per checkout to avoid that.

4. How should a FastAPI app give each request its own Session?

  1. One global Session created at import time
  2. A new Engine and Session inside every endpoint
  3. A `yield` dependency that opens `with Session(engine)` and yields it
  4. Store the Session on `app.state`
Show the answer

A `yield` dependency that opens `with Session(engine)` and yields it — The dependency runs per request, and the code after yield runs when the request ends, so the session always closes. A global Session would be shared across concurrent requests — not thread-safe, and one failure poisons everyone.

5. What's the difference between pool_size and max_overflow?

  1. They're aliases
  2. pool_size connections stay open between requests; max_overflow extra ones open under load and close when returned
  3. pool_size is per thread, max_overflow is per process
  4. max_overflow is the queue length of waiting requests
Show the answer

pool_size connections stay open between requests; max_overflow extra ones open under load and close when returned — pool_size is the warm set kept around. Overflow connections exist only while demand exceeds it. Neither limits how many requests may wait — pool_timeout bounds how long they wait.

6. What happens when 15 connections (5 + 10 overflow) are checked out and a 16th request asks for one?

  1. SQLAlchemy opens a 16th connection anyway
  2. The request fails instantly
  3. The oldest connection is taken from its owner
  4. It waits up to pool_timeout (30 s), then raises TimeoutError
Show the answer

It waits up to pool_timeout (30 s), then raises TimeoutError — Reproduced with pool_size=2, max_overflow=1, pool_timeout=1: the 4th checkout waited 1.0 s and raised 'QueuePool limit of size 2 overflow 1 reached, connection timed out'. The hard cap is the point — it protects the database.

There are 9 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →