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.

Backend Engineerpythonpostgresqlfastapi

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

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

Fix a Race Condition That Double-ChargesBackend projectFix EADDRINUSE: Port Already in UseBackend projectReturn the Correct HTTP Status CodeBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

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 →