Plate 77
contextlib vs try/finally: Cleanup Lab
Aditya Challa4 min read
Intro — what this post promises
Is contextlib.closing / ExitStack “free” compared with a manual try / finally? This lab times file-like open → read → close patterns on Linux localhost: native with, closing(), @contextmanager, and ExitStack vs bare try/finally.
Related links:
- ThreadPoolExecutor vs sequential localhost lab
- itemgetter vs lambda sort localhost lab
- perf_counter vs time localhost lab
- logging vs print localhost lab
- copy vs deepcopy localhost lab
- lru_cache hit vs miss localhost lab
- itertools vs python loops localhost lab
- array vs list ints localhost lab
Lab honesty (1 Oct 2026 IST): Python 3.13.5. N=50,000 open/close cycles per arm. FakeFile stands in for objects that expose close() but may lack __enter__ (the closing() use case). Affiliates: 0. Not a correctness substitute — context managers still win for nested cleanup and exceptions.
Verdict up front: bare try/finally ~8.3M/s; native with ~4.3M (~1.9× slower); closing() ~2.9M (~2.8×); ExitStack + one closing ~0.54M (~15×). Prefer clarity; pay ExitStack only when you need dynamic stacks.
Arms
| Arm | Pattern |
|---|---|
| try/finally | f=FakeFile(); try: read; finally: close |
native with | FakeFile implements __enter__/__exit__ |
closing() | with closing(FakeFile()) |
@contextmanager | generator CM wrapping FakeFile |
| ExitStack ×1 / ×3 | enter_context(closing(...)) |
| try/finally ×3 | three files, reverse close |
| BytesIO | try/finally vs closing(BytesIO) |
Lab topology
Script: lab-evidence/60-contextlib-vs-try-finally/results/run_lab.py.
Lead table — FakeFile (p50)
| Arm | ops/s | ns/op |
|---|---|---|
| try/finally | 8,323,021 | 120.1 |
native with | 4,270,934 | 234.1 |
closing() | 2,933,460 | 340.9 |
@contextmanager | 1,132,779 | 882.8 |
| ExitStack ×1 | 544,158 | 1837.7 |
| try/finally ×3 | 2,672,477 | 374.2 |
| ExitStack ×3 | 417,639 | 2394.4 |
BytesIO check
| Arm | ops/s | ns/op |
|---|---|---|
| BytesIO try/finally | 12,952,516 | 77.2 |
BytesIO closing() | 3,507,039 | 285.1 |
Same shape: try/finally ~3.7× closing() on this path.
Reading it
- Protocol cost is real but small in absolute ns — ~120 ns try/finally vs ~341 ns
closing()per cycle. You will not see this on a disk read that takes milliseconds. ExitStackis for dynamic / many resources — here one-slot ExitStack was ~15× slower than try/finally; three-resource still ~6.4×. Worth it when resource count is data-driven.- Native
withbeatsclosing()(~1.46×) when the object already implements the CM protocol — prefer implementing__enter__/__exit__over wrapping. @contextmanagersits betweenclosingand ExitStack (~883 ns) — generator setup shows up in a tight loop.
Why this still prefers context managers in prod
The microbench ranks bare try/finally first because it does the least work. Production code usually values exception-safe nested cleanup, readable scopes, and libraries that only expose close(). Paying a few hundred nanoseconds per enter/exit is rational next to any real I/O. Strip wrappers only after a profiler names the CM path.
Pitfalls
- Microbenching cleanup and skipping I/O — the close path is rarely your bottleneck.
- Hand-rolling nested finally — easy to leak on partial failure; ExitStack exists for a reason.
closing()on an object that already supportswith— double-wrap noise.- Assuming ExitStack is “heavy” in wall-clock APIs — microseconds vs network RTT.
When to pick what
| Need | Prefer |
|---|---|
| One resource, hottest path | try/finally or native with |
Object has close() only | contextlib.closing |
| Dynamic / variable count | ExitStack |
| Custom setup/teardown | @contextmanager / CM class |
Reproduce
Evidence: /workspace/lab-evidence/60-contextlib-vs-try-finally/results/.
Closing
Correctness first; nanoseconds second. On this box try/finally hit ~8.3M/s vs closing() ~2.9M (~2.8×) and ExitStack-one ~15× behind. Use contextlib for safety and nesting; only strip it after a profiler points at the enter/exit path.
Lab evidence
What I found running this
Lab 1 Oct 2026 IST. Python 3.13.5; N=50000. try/finally FakeFile 8.32M vs closing 2.93M (~2.84x); ExitStack one ~0.54M (~15.3x slower than try/finally); three-resource try/finally vs ExitStack ~6.4x. Affiliates: 0. Evidence: lab-evidence/60-contextlib-vs-try-finally/.
Related links
Plate 17
platform vs os.uname Inventory: Localhost Lab
Hands-on platform.platform vs os.uname host inventory lab: real ops/s plus cache notes, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 50
signal vs threading.Event Wakeup: Localhost Lab
Hands-on signal SIGUSR1 vs threading.Event wakeup lab: real p50 latency in microseconds, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 76
cmath vs math.hypot Magnitudes: Localhost Lab
Hands-on cmath vs math.hypot magnitude ops lab: real ops/s for abs, polar, and phase, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026