ShopperCove
Menu
All writingBlogTopicsCategoriesAboutRSS
Blog
Categories
Observability & SRE62All categories
About

Plate 77

  1. Blog

contextlib vs try/finally: Cleanup Lab

Aditya Challa·30 September 2026·4 min read

Summary
On this page
  1. Intro — what this post promises
  2. Arms
  3. Lab topology
  4. Lead table — FakeFile (p50)
  5. BytesIO check
  6. Reading it
  7. Why this still prefers context managers in prod
  8. Pitfalls
  9. When to pick what
  10. Reproduce
  11. Closing

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

ArmPattern
try/finallyf=FakeFile(); try: read; finally: close
native withFakeFile implements __enter__/__exit__
closing()with closing(FakeFile())
@contextmanagergenerator CM wrapping FakeFile
ExitStack ×1 / ×3enter_context(closing(...))
try/finally ×3three files, reverse close
BytesIOtry/finally vs closing(BytesIO)

Lab topology

N = 50000 cycles/arm; repeats=9; metric = p50 ops/s
FakeFile: __slots__, read(), close(); optional CM protocol

Script: lab-evidence/60-contextlib-vs-try-finally/results/run_lab.py.


Lead table — FakeFile (p50)

Armops/sns/op
try/finally8,323,021120.1
native with4,270,934234.1
closing()2,933,460340.9
@contextmanager1,132,779882.8
ExitStack ×1544,1581837.7
try/finally ×32,672,477374.2
ExitStack ×3417,6392394.4

BytesIO check

Armops/sns/op
BytesIO try/finally12,952,51677.2
BytesIO closing()3,507,039285.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.
  • ExitStack is 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 with beats closing() (~1.46×) when the object already implements the CM protocol — prefer implementing __enter__/__exit__ over wrapping.
  • @contextmanager sits between closing and 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

  1. Microbenching cleanup and skipping I/O — the close path is rarely your bottleneck.
  2. Hand-rolling nested finally — easy to leak on partial failure; ExitStack exists for a reason.
  3. closing() on an object that already supports with — double-wrap noise.
  4. Assuming ExitStack is “heavy” in wall-clock APIs — microseconds vs network RTT.

When to pick what

NeedPrefer
One resource, hottest pathtry/finally or native with
Object has close() onlycontextlib.closing
Dynamic / variable countExitStack
Custom setup/teardown@contextmanager / CM class

Reproduce

python3 lab-evidence/60-contextlib-vs-try-finally/results/run_lab.py

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.

contextlibclosingexitstacktry/finallycontext managerpythonlocalhost labsre

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/.

Notes when a lab post goes up

Occasional email for new hands-on reviews. No sequence and no sponsors.

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

On this page

  1. Intro — what this post promises
  2. Arms
  3. Lab topology
  4. Lead table — FakeFile (p50)
  5. BytesIO check
  6. Reading it
  7. Why this still prefers context managers in prod
  8. Pitfalls
  9. When to pick what
  10. Reproduce
  11. Closing
All writingBlogCategoriesTopicsAboutPrivacyRSS

© 2026 ShopperCove