Intermediate
You are writing a data pipeline that opens five CSV files, acquires a threading lock, and connects to a database — all at once. The classic with statement handles one or two resources cleanly, but by the time you have five nested with blocks, your code has drifted four indentation levels to the right and cleanup order is anyone’s guess. Worse, the number of files is only known at runtime, so you cannot even write five static with statements — you need to open them in a loop.
Python’s contextlib.ExitStack was designed exactly for this situation. It is a context manager that manages other context managers, letting you enter and exit any number of them — determined at runtime — with a single with block. Everything in the standard library that works with with works with ExitStack. No third-party packages required.
This article covers what ExitStack is and when to reach for it, how to add context managers dynamically with enter_context(), how to register arbitrary cleanup functions with callback(), how error handling and suppression work, and patterns for optional or conditional resources. By the end you will have built a real multi-file log merger that opens any number of files cleanly and guarantees cleanup even when exceptions occur.
ExitStack in Python: Quick Example
Here is the minimal case: opening a variable number of files with ExitStack and reading from all of them, with guaranteed cleanup regardless of errors.
# exitstack_quick.py
import contextlib
import tempfile
import os
# Create three temporary files with sample data
paths = []
for i in range(3):
f = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
f.write(f"Line from file {i+1}\n")
f.close()
paths.append(f.name)
# Open all three files at once using ExitStack
with contextlib.ExitStack() as stack:
file_handles = [stack.enter_context(open(p)) for p in paths]
for fh in file_handles:
print(fh.read().strip())
# Cleanup
for p in paths:
os.unlink(p)
Output:
Line from file 1
Line from file 2
Line from file 3
The key call is stack.enter_context(open(p)). Each call activates one context manager and registers its __exit__ method with the stack. When the with block ends — normally or via exception — the stack calls every registered __exit__ in reverse order, exactly as nested with statements would. Whether you enter two context managers or twenty, the pattern stays the same.
The sections below explain how this mechanism works, cover more advanced usage including callback() and error suppression, and show patterns for optional and conditional resources.
What Is ExitStack and When Do You Need It?
A context manager in Python is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — even if an exception is raised. This is why with open(...) closes the file even when your code crashes halfway through reading it.
Nested with statements handle two or three resources reasonably well. But they have two hard limits: the number must be known at write time, and each level adds one indentation. ExitStack removes both limits. Think of it as a list that accumulates __exit__ callbacks as you enter context managers, then drains the list in reverse order when the block ends. The “last in, first out” cleanup order mirrors what nested with statements would produce.
| Situation | Nested with | ExitStack |
|---|---|---|
| Fixed 2-3 resources | Fine | Works, but overkill |
| Fixed 4+ resources | Deep indentation | Flat, readable |
| Variable number of resources | Not possible | Designed for this |
| Conditional resource (open only if flag is set) | Awkward nested if/with | enter_context() inside an if |
| Arbitrary cleanup callback (not a context manager) | Not possible | stack.callback(fn) |
| Suppress exceptions from cleanup | Not possible without try/except | Built-in via __exit__ return value |
ExitStack lives in the contextlib module, which ships with Python’s standard library — nothing to install. Import it with from contextlib import ExitStack or use contextlib.ExitStack directly.
Adding Context Managers Dynamically with enter_context()
enter_context(cm) is the main API. It calls cm.__enter__(), registers cm.__exit__ with the stack, and returns whatever __enter__ returned — exactly the same value you would get from as in a plain with statement. You can call it as many times as you need, in any order, including inside loops and conditionals.
# enter_context_demo.py
import contextlib
import threading
import tempfile
import os
lock = threading.Lock()
# Open a dynamic number of files plus a lock in one block
filenames = [tempfile.mktemp(suffix='.log') for _ in range(4)]
for name in filenames:
open(name, 'w').close() # create them
with contextlib.ExitStack() as stack:
# Acquire the lock first -- it is released last (LIFO order)
stack.enter_context(lock)
# Open all log files
handles = [stack.enter_context(open(name, 'r')) for name in filenames]
print(f"Lock acquired: {lock.locked()}")
print(f"Files open: {len(handles)}")
# ... process handles here ...
# After the block: lock released, all files closed
print(f"Lock still held: {lock.locked()}")
print(f"File closed: {handles[0].closed}")
# Cleanup temp files
for name in filenames:
os.unlink(name)
Output:
Lock acquired: True
Files open: 4
Lock still held: False
File closed: True
Notice the cleanup order: the lock was entered first and released last. ExitStack always calls __exit__ in reverse entry order, which mirrors what you would get from innermost-to-outermost nested with blocks. If cleanup order matters for your resources — and for locks it usually does — enter them in the order you want them released in reverse.
Registering Arbitrary Cleanup with callback()
Not everything you need to clean up is a context manager. Sometimes you have a function call, a database rollback, a temp directory deletion, or a log flush that needs to run on exit. stack.callback(fn, *args, **kwargs) registers any callable as a cleanup step — it runs at the same time as __exit__ calls, in LIFO order, and is guaranteed to run even if an exception is raised.
# callback_demo.py
import contextlib
import tempfile
import shutil
import os
def create_workspace():
"""Create a temp directory and return its path."""
return tempfile.mkdtemp(prefix='job_')
def log_cleanup(path):
print(f"Cleaning up workspace: {path}")
with contextlib.ExitStack() as stack:
workspace = create_workspace()
# Register cleanup as a callback -- shutil.rmtree is not a context manager
stack.callback(log_cleanup, workspace)
stack.callback(shutil.rmtree, workspace, ignore_errors=True)
# Do work inside the workspace
output_file = os.path.join(workspace, 'result.txt')
with open(output_file, 'w') as f:
f.write('processing complete\n')
print(f"Workspace exists: {os.path.isdir(workspace)}")
print(f"Output file: {os.path.exists(output_file)}")
print(f"Workspace removed: {not os.path.isdir(workspace)}")
Output:
Workspace exists: True
Output file: True
Cleaning up workspace: /tmp/job_xyz123
Workspace removed: True
The callback registered last runs first. Here shutil.rmtree is registered before log_cleanup, so the log message prints before the directory is deleted — that is the reverse registration order. Think carefully about the order when callbacks depend on each other. Also note that callback() accepts positional and keyword arguments directly, so you never need a lambda wrapper: stack.callback(shutil.rmtree, workspace, ignore_errors=True) is cleaner and safer than stack.callback(lambda: shutil.rmtree(workspace, ignore_errors=True)).
Optional and Conditional Resources
One of the most practical uses of ExitStack is managing resources that are only opened under certain conditions — for example, opening a second output file only when a --verbose flag is passed, or acquiring a distributed lock only in production. With nested with statements, conditional resources require awkward outer if blocks or dummy no-op context managers. With ExitStack, you simply call enter_context() inside a conditional.
# conditional_resource.py
import contextlib
import sys
import os
import tempfile
verbose = '--verbose' in sys.argv
# Simulate an output file path
output_path = tempfile.mktemp(suffix='.log')
with contextlib.ExitStack() as stack:
out = open(output_path, 'w')
stack.enter_context(out)
# Only open a debug log if verbose mode is on
if verbose:
debug_path = tempfile.mktemp(suffix='.debug')
debug = stack.enter_context(open(debug_path, 'w'))
debug.write("debug mode active\n")
print(f"Debug log: {debug_path}")
else:
debug = None
print("Running in normal mode (no debug log)")
out.write("result: OK\n")
if debug:
debug.write("result written to output\n")
print(f"Output file closed: {out.closed}")
os.unlink(output_path)
Output (without –verbose flag):
Running in normal mode (no debug log)
Output file closed: True
The single with contextlib.ExitStack() block handles both cases. When verbose is False, only the output file is registered. When it is True, both files are registered and both are closed on exit. No dummy context managers, no nested if/with combinations, no cleanup logic repeated in two branches.
Error Handling and Exception Suppression
ExitStack mirrors the exception behavior of nested with statements. If an exception occurs inside the block, it is passed to each registered __exit__ in reverse order. Any __exit__ that returns a truthy value suppresses the exception. If the exception survives all cleanup calls, it propagates normally. You can also suppress exceptions explicitly with contextlib.suppress entered onto the stack.
# error_handling.py
import contextlib
class NoisyResource:
"""A context manager that prints on entry and exit."""
def __init__(self, name):
self.name = name
def __enter__(self):
print(f" Opened: {self.name}")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print(f" Closed: {self.name} (exc={exc_type is not None})")
return False # do not suppress exceptions
print("--- Normal exit ---")
with contextlib.ExitStack() as stack:
stack.enter_context(NoisyResource("A"))
stack.enter_context(NoisyResource("B"))
stack.enter_context(NoisyResource("C"))
print()
print("--- Exception during body ---")
try:
with contextlib.ExitStack() as stack:
stack.enter_context(NoisyResource("X"))
stack.enter_context(NoisyResource("Y"))
raise ValueError("something went wrong")
except ValueError as e:
print(f" Caught: {e}")
print()
print("--- Suppress a specific exception type ---")
with contextlib.ExitStack() as stack:
stack.enter_context(contextlib.suppress(FileNotFoundError))
stack.enter_context(NoisyResource("Z"))
raise FileNotFoundError("missing.txt") # suppressed
print(" Continued after suppressed exception")
Output:
--- Normal exit ---
Opened: A
Opened: B
Opened: C
Closed: C (exc=False)
Closed: B (exc=False)
Closed: A (exc=False)
--- Exception during body ---
Opened: X
Opened: Y
Closed: Y (exc=True)
Closed: X (exc=True)
Caught: something went wrong
--- Suppress a specific exception type ---
Opened: Z
Closed: Z (exc=True)
Continued after suppressed exception
All three resources are closed even when an exception is raised — the exception information is passed to each __exit__ but neither suppresses it, so it propagates after cleanup. The third block shows contextlib.suppress(FileNotFoundError) entered onto the stack: when FileNotFoundError is raised, the suppress context manager’s __exit__ returns True, and the exception is silently discarded.
Detaching the Stack for Deferred Cleanup
Sometimes you want to open resources and pass them elsewhere — to a class, a worker thread, or a long-lived object — and delay cleanup until that object is done. stack.pop_all() transfers all registered cleanup handlers to a new ExitStack and returns it, leaving the original empty. The new stack can then be stored as an instance variable and closed explicitly later.
# deferred_cleanup.py
import contextlib
import tempfile
import os
class FileProcessor:
"""Opens a set of files and keeps them available until close() is called."""
def __init__(self, paths):
self._stack = contextlib.ExitStack()
self.handles = []
try:
for path in paths:
fh = self._stack.enter_context(open(path))
self.handles.append(fh)
except Exception:
# If any open fails, close what we opened so far
self._stack.close()
raise
def process(self):
for fh in self.handles:
print(f" Reading {fh.name}: {fh.read().strip()}")
def close(self):
self._stack.close()
# Create temp files
paths = []
for i in range(3):
f = tempfile.NamedTemporaryFile(mode='w', suffix='.dat', delete=False)
f.write(f"data from file {i+1}")
f.close()
paths.append(f.name)
processor = FileProcessor(paths)
print("Processing:")
processor.process()
processor.close()
print(f"File 0 closed: {processor.handles[0].closed}")
for p in paths:
os.unlink(p)
Output:
Processing:
Reading /tmp/tmpXXX.dat: data from file 1
Reading /tmp/tmpXXX.dat: data from file 2
Reading /tmp/tmpXXX.dat: data from file 3
File 0 closed: True
The try/except block inside __init__ is important: if the third file fails to open, self._stack.close() ensures the first two are also closed before the exception propagates. Without it, those file handles would leak. This pattern — open resources inside a stack, then store the stack, then close it explicitly — is the standard approach for long-lived objects that manage multiple resources.
Real-Life Example: Multi-File Log Merger
Here is a practical script that merges any number of timestamped log files into a single sorted output file. The number of input files is determined at runtime from a directory scan, making ExitStack the right tool for the job.
# log_merger.py
import contextlib
import tempfile
import os
import heapq
def create_sample_logs(base_dir, num_files=4):
"""Create sample log files with interleaved timestamps."""
import random
paths = []
for i in range(num_files):
path = os.path.join(base_dir, f"service_{i+1}.log")
with open(path, 'w') as f:
lines = sorted(
f"2026-08-13T0{h:02d}:{m:02d}:00 [service-{i+1}] event-{j}\n"
for j, (h, m) in enumerate(
(random.randint(0, 9), random.randint(0, 59))
for _ in range(5)
)
)
f.writelines(lines)
paths.append(path)
return paths
def merge_logs(input_paths, output_path):
"""Merge sorted log files into one sorted output file."""
with contextlib.ExitStack() as stack:
# Open all input files dynamically
handles = [stack.enter_context(open(p)) for p in input_paths]
# Open the output file on the same stack
out = stack.enter_context(open(output_path, 'w'))
# Register a callback to print a summary on exit
line_count = [0]
def report():
print(f" Merged {len(handles)} files -> {line_count[0]} lines total")
stack.callback(report)
# Merge-sort across all files using heapq
for line in heapq.merge(*handles):
out.write(line)
line_count[0] += 1
# Stack has exited: all files closed, callback fired
print(f" Output written to: {output_path}")
# Run the demo
with tempfile.TemporaryDirectory() as tmpdir:
log_paths = create_sample_logs(tmpdir, num_files=4)
output = os.path.join(tmpdir, 'merged.log')
print("Merging logs...")
merge_logs(log_paths, output)
# Show first 5 lines of result
print("\nFirst 5 lines of merged output:")
with open(output) as f:
for i, line in enumerate(f):
if i >= 5:
break
print(f" {line.rstrip()}")
Output:
Merging logs...
Merged 4 files -> 20 lines total
Output written to: /tmp/tmpXXX/merged.log
First 5 lines of merged output:
2026-08-13T000:12:00 [service-2] event-3
2026-08-13T000:17:00 [service-4] event-1
2026-08-13T001:08:00 [service-1] event-0
2026-08-13T001:23:00 [service-3] event-2
2026-08-13T002:45:00 [service-1] event-4
The ExitStack block opens all four input files plus the output file in a single flat with. The callback() for the summary report fires after the output file is flushed and closed — the right order for accurate reporting. You can extend this by adding a threading lock to the stack (if merging concurrently), entering a contextlib.suppress(BrokenPipeError) for piped output, or registering a callback to upload the merged file when done.
Frequently Asked Questions
When should I use ExitStack instead of nested with statements?
Use ExitStack when the number of context managers is not known until runtime (e.g., opening files from a list), when you have more than three or four fixed resources and the indentation becomes unreadable, or when you need to conditionally enter a context manager inside an if block. For two or three fixed, always-opened resources, nested with is simpler and more readable. ExitStack is the upgrade path when nested with starts to hurt.
What happens if an exception is raised inside a cleanup callback?
If a callback registered with stack.callback() raises an exception, ExitStack catches it, continues running the remaining cleanup handlers, and then re-raises the exception (or the original body exception, whichever takes precedence). This means all registered cleanups run even if some of them fail — the same guarantee provided by nested with statements. To suppress errors from specific callbacks, wrap the callable yourself: stack.callback(lambda: quietly_close(resource)) where quietly_close catches and logs its own exceptions.
What does pop_all() do and when do I need it?
stack.pop_all() transfers all registered cleanup handlers from the current stack to a new ExitStack instance and returns it, leaving the original stack empty. This is used when you want to open resources inside a temporary setup block but then keep them alive beyond that block’s scope. The typical pattern is a class that opens resources in __init__ using a temporary ExitStack, then calls pop_all() at the end to store the cleanup handlers as self._stack, which is closed in close() or __exit__.
Is ExitStack thread-safe?
No — ExitStack itself is not thread-safe. If multiple threads could be entering or exiting context managers on the same stack simultaneously, you need to synchronize access with a threading.Lock. In practice, each thread typically has its own ExitStack for its own resources, which avoids the issue entirely. If you need a shared cleanup registry across threads, build one explicitly using a lock.
Does ExitStack work with async context managers?
Standard ExitStack only works with synchronous context managers (those using __enter__/__exit__). For async code using async with and __aenter__/__aexit__, use contextlib.AsyncExitStack instead. It has the same API — enter_async_context(), callback(), push() — but is awaitable and must be used with async with. Both classes are in contextlib and require no extra installation.
Can I use ExitStack without a with statement?
Yes — you can manage it manually. Call stack = contextlib.ExitStack(), use stack.enter_context() to add resources, and call stack.close() when done. Wrap the whole thing in a try/finally to guarantee close() runs even on exceptions: try: ... finally: stack.close(). Using with ExitStack() as stack is cleaner and equivalent, but the manual form is useful when the stack lives inside a class that needs explicit lifecycle management.
Conclusion
In this article we covered what contextlib.ExitStack is and how it manages multiple context managers in LIFO order, how to add context managers dynamically with enter_context(), how to register arbitrary cleanup functions with callback(), how to handle conditional and optional resources cleanly, how exception propagation and suppression work with the stack, and how to use pop_all() for long-lived objects that own their resources.
A good next step is to extend the log merger from the real-life example: add contextlib.suppress(BrokenPipeError) for piped output, enter a threading.Lock onto the stack when running the merger in a thread pool, or swap the callback for structured logging. For async pipelines, the same patterns apply to contextlib.AsyncExitStack with async with.
The official Python documentation for contextlib is at docs.python.org/3/library/contextlib.html. It covers ExitStack, AsyncExitStack, suppress, contextmanager, and the full list of utility context managers in the standard library.