How do context managers help ensure cleanup even when exceptions occur?
Quick Answer
A context manager's `__exit__` method is **guaranteed to run** when leaving a `with` block, whether the block completed normally or raised an exception — this makes `with` strictly safer than relying on cleanup code written after the risky operation, which would simply never execute if an exception was raised and not caught before reaching it.
Detailed Answer
The bug pattern with prevents
# BUG: if write() raises, close() never runs -- the file handle leaks
f = open("data.txt", "w")
f.write(risky_computation())
f.close()
If risky_computation() raises an exception, execution jumps straight
out of this code, skipping f.close() entirely — the file handle is
never released (in CPython, the garbage collector will eventually close
it via __del__ when the object is collected, but that timing isn't
guaranteed, and other resources like locks or database connections may
not have any such fallback at all).
The with statement: __exit__ always runs
with open("data.txt", "w") as f:
f.write(risky_computation()) # if this raises...
# ...f.close() (via __exit__) STILL runs, then the exception propagates normally
Entering the with block calls __enter__; leaving it — for any
reason, including an uncaught exception — calls __exit__, which for a
file object closes it. The exception itself still propagates after
__exit__ finishes (unless __exit__ explicitly suppresses it by
returning True), so you get both guaranteed cleanup and normal error
visibility.
Why this is strictly better than manual try/finally
# Equivalent manual version -- works, but easy to get wrong at scale
f = open("data.txt", "w")
try:
f.write(risky_computation())
finally:
f.close()
Both are correct here, but with scales better: acquiring multiple
resources with manual try/finally requires careful nesting (each
resource's finally must be inside the previous one's try) to avoid
leaking earlier resources if a later acquisition fails, whereas multiple
with context managers in one statement handle this automatically and
correctly:
with open("in.txt") as fin, open("out.txt", "w") as fout:
fout.write(fin.read())
# both files are guaranteed closed, in reverse order, however the block exits
Real-world cleanup scenarios this protects against
- File handles: unclosed files can exhaust the OS's file descriptor limit under sustained load.
- Locks: a lock acquired but never released due to an exception can
deadlock every other thread waiting on it —
with lock:guarantees release. - Database transactions/connections:
__exit__can commit on success and roll back on exception (inspecting theexc_typeargument it receives), ensuring a transaction is never left half-applied. - Network connections: guaranteed closing prevents leaking sockets under repeated failures.
Interview-ready summary: with blocks guarantee that a resource's
cleanup (__exit__) runs regardless of whether the block succeeded or
raised, which manual cleanup code placed after risky operations cannot
guarantee — this is why "acquire a resource" almost always pairs with
with rather than a bare acquire-then-use-then-release sequence.