Every request opens a handle and forgets to close it — the pool fills until new work is turned away.
File descriptors, database connections, and sockets all come from a finite pool. The contract is acquire-then-release: borrow a slot, use it, give it back. A resource leak happens when some code path acquires a handle but never releases it — usually because an early return or an exception skips the close.
One leaked handle per request is invisible at first. But the pool only holds so many slots, so leaks accumulate until the free count hits zero. Then the next acquire can’t get a slot: it blocks until a timeout, or fails outright with “too many open files.” The fix is to make release unconditional — a context manager or try/finally that runs on every path.
The leaking version closes the handle on the happy path only. The moment an early return or an exception fires before close(), the slot is gone for good. Wrapping the work in a context manager — or a try/finally — guarantees the release runs no matter how the function exits.
# Leaking: close() is skipped on early return or exception
def handle(req):
f = open("/data/work.bin") # acquire a slot
rows = parse(f) # if this raises, close() never runs
if rows.empty:
return None # early return leaks the handle
f.close() # only reached on the happy path
return rows
# Safe: the context manager releases on EVERY exit path
def handle(req):
with open("/data/work.bin") as f: # acquire
rows = parse(f)
if rows.empty:
return None # __exit__ still closes f
return rows # so does this
# f is closed here whether we returned, raised, or fell through
The with form removes the question entirely: there is no path where the slot stays held. For pools you don’t own the open/close of, the same shape is try/finally with the release in the finally block.
| Dimension | What to know |
|---|---|
| Failure mode | Pool exhaustion: new acquires block to timeout or fail with “too many open files” |
| Signal | Open file-descriptor / connection count climbing monotonically, never falling back |
| Signal | Pool wait-time growing and “too many open files” / pool-timeout errors appearing |
| Signal | Handle and memory graphs that only go up — a sawtooth would be healthy, a ramp is a leak |
| Fix cost | Low — wrap acquisition in a context manager or try/finally so release is unconditional |
if … return that jumps past close() leaks the slot every time it fires.finally.with (or defer, RAII, using) ties release to scope exit.An import endpoint opens a database connection from a pool of 8, parses an uploaded file, and returns. On a malformed file, parse() raises before the conn.close() line — so each bad upload leaks one connection. Tests only feed clean files, so it ships green. In production, a batch of malformed uploads leaks connections one by one: free slots fall 8 to 5 to 3 to 1, pool wait-time climbs, and then a new request is rejected with a pool timeout while the database itself sits idle. The fix is one line of shape: acquire with with pool.connection() as conn: so the connection is returned on the raising path too. Re-running the same malformed batch now drains cleanly — free slots recover after every request.
Why does a leak that “only happens on the error path” survive tests but break prod?
What makes with open(…) safer than a manual close()?