How to Fix a Connection Pool Leak (Like a Node JS Memory Leak)
An API that throws intermittent 500 errors only under load usually has a resource leak - the connection-pool equivalent of a node js memory leak. Here a psycopg2 pool with maxconn=5 runs dry because the error path skips putconn. The fix is wrapping getconn/putconn in try/finally so every connection returns to the pool, even when the handler raises.
Why the API dies only under load
A connection pool caps how many database connections exist at once. This FastAPI service uses a small psycopg2 pool so the bug shows up fast:
from psycopg2 import pool
db_pool = pool.SimpleConnectionPool(minconn=1, maxconn=5, dsn=DATABASE_URL)
Each handler borrows a connection with db_pool.getconn() and is expected to give it
back with db_pool.putconn(conn). The trap is the error path. Look at get_user:
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
conn = db_pool.getconn()
cur = conn.cursor()
cur.execute("SELECT id, name, email FROM users WHERE id = %s", (user_id,))
row = cur.fetchone()
cur.close()
if row is None:
# BUG: connection is never returned to the pool
raise HTTPException(status_code=404, detail="User not found")
db_pool.putconn(conn)
return {"id": row[0], "name": row[1], "email": row[2]}
When the user exists, putconn runs and the connection returns. When it does not, the
raise fires first and putconn is skipped. That one connection is leaked forever.
Just like an unreleased buffer in a node js memory leak, each leaked connection is
small on its own - but they accumulate. After 5 requests for a missing ID the pool is
empty, and every new request blocks or 500s until something times out.
Step 1: reproduce the exhaustion
Hammer an endpoint that always raises, then check whether the API still answers:
# Trigger the leak by hitting non-existent user IDs
for i in $(seq 1 10); do curl -s http://localhost:8000/api/users/9999; done
# A working endpoint should still respond - after the leak it hangs or 500s
curl -s http://localhost:8000/api/users
Five bad requests drain a maxconn=5 pool. The healthy /api/users route now fails
even though its own code is correct - it simply cannot borrow a connection.
Step 2: find every leaking handler
Any handler that calls getconn without a finally is suspect:
grep -n "getconn\|putconn\|finally" /workspace/main.py
In this service three handlers leak: get_user (404 on missing row), create_user
(unique-violation on a duplicate email), and create_order (foreign-key violation on
a bad user_id). Each acquires a connection, then hits a raise before putconn.
Step 3: wrap acquire/release in try/finally
The finally block runs whether the body returns or raises, so the connection always
goes back:
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
conn = db_pool.getconn()
try:
cur = conn.cursor()
cur.execute("SELECT id, name, email FROM users WHERE id = %s", (user_id,))
row = cur.fetchone()
cur.close()
if row is None:
raise HTTPException(status_code=404, detail="User not found")
return {"id": row[0], "name": row[1], "email": row[2]}
finally:
db_pool.putconn(conn)
Apply the same shape to create_user and create_order. Then restart the server so
the change takes effect:
pkill -f 'uvicorn main:app'
cd /workspace && uvicorn main:app --host 0.0.0.0 --port 8000 &
Re-run the error storm from Step 1 and the pool recovers - /api/users returns 200
every time because the failed requests hand their connections back.
The durable fix: a context manager
try/finally is correct but easy to forget on the next handler. Prefer a context
manager that returns the connection for you, so no future error path can leak:
from contextlib import contextmanager
@contextmanager
def get_conn():
conn = db_pool.getconn()
try:
yield conn
finally:
db_pool.putconn(conn)
Now with get_conn() as conn: is impossible to leak - cleanup is guaranteed by the
language, not by remembering it at every return and raise.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Reproducing pool exhaustion by hitting an endpoint that always raises
- Wrapping psycopg2 getconn/putconn in try/finally so errors release connections
- Refactoring acquire/release into a context manager so no handler can leak
FAQ
What causes intermittent 500 errors under load in a FastAPI app?
The most common cause is a resource leak, usually a database connection that is not returned to the pool on the error path. Each failed request keeps one connection out of circulation; once the pool cap is reached, new requests block or 500 even though the code looks correct.
How do I fix a Postgres connection pool leak in Python?
Wrap the getconn call in a try block and call putconn in a finally block so the connection returns whether the handler succeeds or raises. Better still, use a context manager that yields the connection and releases it in finally, so no future code path can skip the release.
Is a connection pool leak the same as a node js memory leak?
They share the same shape - a resource that is acquired but never released, so it accumulates until the app fails. A node js memory leak retains objects the garbage collector cannot free; a pool leak retains connections the pool cannot reuse. Both surface as slow-then-dead behavior under sustained load, and both are fixed by guaranteeing cleanup on every path.
Why does putconn not run when my handler raises an exception?
A raise or an unhandled exception exits the function immediately, skipping any code below it - including a plain db_pool.putconn(conn) call. Only a finally block (or a context manager) runs during exception unwinding, which is why cleanup must live there.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →