Transactions, Locking & Concurrency in Code · Transactions in Application Code

Connection Pools and Transactions

How pool exhaustion really happens, how to size a pool, and what PgBouncer's transaction mode breaks.

Scenario

Example app: a product API with 8 pods, each running a pool of 10 connections (80 total) to a Postgres server configured with max_connections = 100. On Black Friday, traffic triples and a recommendation service it calls slows to 2 s.

What you see in production

Error logs fill with pool timeouts while the database itself looks calm:

SQLAlchemy: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00
Java/Hikari: HikariPool-1 - Connection is not available, request timed out after 30000ms.

Someone raises the pool size to 50 per pod — and now Postgres rejects connections with FATAL: sorry, too many clients already.

The naive code

The request opens a session (checking out a connection) at the start and calls a slow service while still holding it:

def product_page(request, product_id):
    with transaction.atomic():                          # connection checked out…
        product = Product.objects.get(pk=product_id)
        recs = recommendations.fetch(product_id)        # …held during a 2 s HTTP call
        Product.objects.filter(pk=product_id).update(views=F("views") + 1)
    return render(request, "product.html", {"product": product, "recs": recs})

Reproduce it

A pool of 10 connections, 40 requests per second, each holding its connection for 5 ms of SQL — plus, in the bad version, a 2 s HTTP call:

Pool exhaustion: holding connections during slow callsTry it yourself
import heapq

def run(hold_seconds, pool_size=10, rps=40, seconds=5, pool_timeout=1.0):
    free_at = [0.0] * pool_size          # when each connection becomes free
    heapq.heapify(free_at)
    timeouts = served = 0
    for i in range(rps * seconds):
        arrive = i / rps
        earliest = free_at[0]
        start = max(arrive, earliest)
        if start - arrive > pool_timeout:
            timeouts += 1                # waited too long for a connection
            continue
        heapq.heapreplace(free_at, start + hold_seconds)
        served += 1
    return served, timeouts

for label, hold in [("SQL only (5 ms)", 0.005), ("SQL + 2 s HTTP call", 2.005)]:
    served, timeouts = run(hold)
    print(f"{label:22} served={served:3}  pool timeouts={timeouts:3}  max throughput ~{10 / hold:,.0f} req/s")

Output

SQL only (5 ms)        served=200  pool timeouts=  0  max throughput ~2,000 req/s
SQL + 2 s HTTP call    served= 30  pool timeouts=170  max throughput ~5 req/s

Root cause

A pool can serve at most pool_size / hold_time requests per second. Holding a connection during non-database work (HTTP calls, template rendering with lazy loading, open-in-view) multiplies the hold time by 100× or more, so the pool runs dry long before the database is busy. Raising the pool size moves the bottleneck to the server: every Postgres connection is a process with its own memory, and more concurrent queries than CPU cores mostly adds contention.

The fix

  1. Hold connections only for database work — fetch, release, then call other services.
  2. Size pools from the database's capacity, not from traffic: total connections across all instances ≤ what the server handles well. The HikariCP pool-sizing guide's starting point is connections ≈ (core_count × 2) + effective_spindle_count for the whole database, divided among the instances.
  3. Put a pooler in front (PgBouncer in transaction mode, RDS Proxy, ProxySQL for MySQL) when you have many app instances or serverless functions.
def product_page(request, product_id):
    product = Product.objects.get(pk=product_id)                       # short query, autocommit
    Product.objects.filter(pk=product_id).update(views=F("views") + 1)
    recs = recommendations.fetch(product_id, timeout=0.5)              # no connection held
    return render(request, "product.html", {"product": product, "recs": recs})
# settings: CONN_MAX_AGE=60, and in front of Postgres: PgBouncer (pool_mode = transaction)

Trade-offs

  • PgBouncer transaction mode gives a connection to a client only for the duration of a transaction, so 1,000 app connections can share 50 server connections. It breaks session state: SET without LOCAL, session advisory locks, LISTEN, and (in older versions) server-side prepared statements. Check what your driver and ORM rely on.
  • Short pool timeouts (1–2 s) turn overload into fast, visible errors instead of 30 s hangs — combine them with load shedding.
  • A dedicated small pool for slow, low-priority work (reports, exports) stops it from starving user requests (the bulkhead pattern).

Detect it in production

  • Pool metrics: active, idle, pending/waiting threads and acquisition time (Hikari exposes these via Micrometer; SQLAlchemy via pool events).
  • Postgres: SELECT state, count(*) FROM pg_stat_activity GROUP BY state; — many idle in transaction sessions mean the app holds connections without using them.
  • Alert when pending connection requests > 0 for more than a minute.

Key points

  • Pool throughput = pool size ÷ hold time; cut the hold time first.
  • Never hold a connection (or transaction) across external calls; disable open-session-in-view.
  • Size pools from database capacity, and share connections with a pooler when instances multiply.
  • Know which session features your pooler mode breaks.

Practice questions

Your service throws pool timeout errors but the database CPU is at 15%. What is the most likely cause?

Answer

Connections are held during non-database work (slow HTTP calls, rendering with lazy loads, open-session-in-view), so the pool runs out while the database is idle. Release connections before external calls.

Why isn't increasing every pod's pool size a good fix for pool exhaustion?

Answer

Total connections (pods × pool size) can exceed the database's max_connections and each extra connection costs server memory and adds contention. Throughput is better improved by shortening how long each connection is held and by using a pooler.

Which application features can break when you switch PgBouncer to transaction pooling?

Answer

Anything that relies on session state across transactions: plain SET (use SET LOCAL), session-level advisory locks, LISTEN/NOTIFY, temporary tables kept across transactions, and server-side prepared statements on older PgBouncer versions.

Open in the app to mark lessons complete, take notes and track your progress.