Context Managers #
Every time you open a file, create a database connection, or acquire a lock, there’s a hidden responsibility: making sure that resource gets closed again — even if an error happens halfway through. Without proper handling, resource leaks are a problem that often stays invisible until the system starts behaving strangely. A context manager is Python’s mechanism for solving this elegantly: you define what happens when entering and leaving a context, and Python guarantees both always execute through the with statement.
The Problem Context Managers Solve #
Without context managers, resource-management code is full of boilerplate and prone to forgetting:
# ANTI-PATTERN: manual resource management — prone to resource leaks
file = open("data.txt", "r")
content = file.read()
file.close() # what if file.read() raises an exception? close() won't be called!
# A manual "safe" version that's correct but verbose:
file = open("data.txt", "r")
try:
content = file.read()
finally:
file.close() # finally ensures close() is always called
# CORRECT: use a context manager — concise and automatically safe
with open("data.txt", "r") as file:
content = file.read()
# file.close() is called automatically here, including when an exception occurs
The with block guarantees that cleanup always runs, no matter what happens inside the block — including uncaught exceptions.
How It Works: The __enter__ and __exit__ Protocol
#
An object can be used as a context manager if it implements two methods:
with MyContext() as obj:
# do something
Execution flow:
1. MyContext().__enter__() is called
→ its return value is bound to the variable `obj`
2. The with block runs
3. MyContext().__exit__(exc_type, exc_val, exc_tb) is called
→ always called, even when an exception occurs
→ if it returns True: the exception is swallowed (not propagated)
→ if it returns False/None: the exception propagates upward
The variable afterasreceives the return value of__enter__(), not the context manager instance itself. Foropen(),__enter__()returns the file object — that’s why you can writewith open(...) as fand usefdirectly.
To visualize how the execution flow and protocol methods are called in sequence, look at the context manager lifecycle diagram below:
flowchart TD
Start["with MyContextManager() as resource"] --> CallEnter["Call __enter__()"]
CallEnter --> Setup["Setup/Allocate the Resource"]
Setup --> ReturnVal["Return the Value to the 'resource' Variable"]
ReturnVal --> RunBody["Run the Code Block inside 'with'"]
RunBody --> CheckExc{"Did an Exception Occur?"}
CheckExc -->|Yes| CallExitExc["Call __exit__(type, value, traceback)"]
CheckExc -->|No| CallExitNormal["Call __exit__(None, None, None)"]
CallExitExc --> CleanupExc["Close/Release the Resource"]
CallExitNormal --> CleanupNormal["Close/Release the Resource"]
CleanupExc --> HandleExc{"Does __exit__ return True?"}
HandleExc -->|Yes| Suppress["Exception Suppressed (Normal Exit)"]
HandleExc -->|No| Propagate["Propagate the Exception (Crash/Bubble Up)"]
CleanupNormal --> End["Program Continues"]
Suppress --> EndCreating a Context Manager with a Class #
The most explicit way is to define a class with __enter__ and __exit__ methods. Good for complex context managers or ones that need to store state.
import time
class Timer:
"""Context manager for measuring a code block's execution time."""
def __enter__(self):
self.start = time.perf_counter()
return self # bound to the variable after `as`
def __exit__(self, exc_type, exc_val, exc_tb):
self.duration = time.perf_counter() - self.start
print(f"Execution time: {self.duration:.4f} seconds")
return False # propagate the exception if any
with Timer() as t:
total = sum(range(10_000_000))
print(f"Total: {total}, stored duration: {t.duration:.4f} seconds")
Another example — a context manager for a database connection:
import sqlite3
class DBConnection:
def __init__(self, db_path):
self.db_path = db_path
self.connection = None
def __enter__(self):
self.connection = sqlite3.connect(self.db_path)
return self.connection # return the connection object, not self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.connection.commit() # success → commit
else:
self.connection.rollback() # error → rollback
self.connection.close()
return False # propagate the exception
with DBConnection("app.db") as db:
cursor = db.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER, name TEXT)")
cursor.execute("INSERT INTO users VALUES (1, 'Budi')")
# automatic commit here — automatic rollback if there's an error
Handling Exceptions in __exit__
#
The exc_type, exc_val, exc_tb parameters give information about the exception that occurred. You can decide whether to swallow it or pass it through:
class SafeContext:
def __enter__(self):
print("Entering context.")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is ValueError:
print(f"ValueError caught and swallowed: {exc_val}")
return True # swallow the exception — code after `with` still runs
if exc_type is not None:
print(f"Other exception passed through: {exc_type.__name__}")
return False # propagate other exceptions
with SafeContext():
raise ValueError("Invalid value")
print("This line still runs because the ValueError was swallowed.")
Be careful when swallowing exceptions withreturn Truein__exit__. This can hide errors that the caller should have handled. Swallow an exception only if you genuinely know it can be safely ignored in that context.
Creating a Context Manager with @contextmanager
#
For simpler context managers, the contextlib module provides the @contextmanager decorator, letting you write a context manager as a generator function — without defining a class.
from contextlib import contextmanager
@contextmanager
def timer(label="Operation"):
import time
start = time.perf_counter()
try:
yield # the `with` block executes here
finally:
duration = time.perf_counter() - start
print(f"{label}: {duration:.4f} seconds")
with timer("Compute total"):
total = sum(range(10_000_000))
The try/yield/finally pattern is the standard idiom for @contextmanager:
from contextlib import contextmanager
import os
@contextmanager
def temp_directory(path):
"""Create a directory, run the block, then delete the directory."""
os.makedirs(path, exist_ok=True)
print(f"Directory '{path}' created.")
try:
yield path # the yielded value is bound to the `as` variable
finally:
import shutil
shutil.rmtree(path)
print(f"Directory '{path}' removed.")
with temp_directory("/tmp/temp_work") as folder:
# write a temporary file
with open(f"{folder}/output.txt", "w") as f:
f.write("temporary data")
print(f"File created at: {folder}/output.txt")
# the directory is automatically removed after the block finishes
Class vs @contextmanager comparison:
Use a class when:
✓ The context manager needs to store complex state
✓ It needs to be inherited or extended
✓ There are many helper methods besides __enter__/__exit__
Use @contextmanager when:
✓ The logic is simple and linear
✓ You want more concise code
✓ No inheritance needed
Managing Multiple Context Managers #
Several with Statements in One Line
#
Python allows opening several context managers at once in a single with statement:
# ANTI-PATTERN: unnecessary nested withs
with open("input.txt", "r") as input_file:
with open("output.txt", "w") as output_file:
output_file.write(input_file.read())
# CORRECT: combine into a single with
with open("input.txt", "r") as input_file, open("output.txt", "w") as output_file:
output_file.write(input_file.read())
ExitStack for a Dynamic Number of Context Managers
#
If the number of context managers isn’t known when the code is written — for example determined by configuration or user input — use contextlib.ExitStack:
from contextlib import ExitStack
def process_many_files(file_list):
with ExitStack() as stack:
# open all files dynamically
file_handles = [
stack.enter_context(open(f, "r"))
for f in file_list
]
# all files are open here
for fh in file_handles:
print(fh.readline())
# all files are automatically closed after the block finishes
process_many_files(["a.txt", "b.txt", "c.txt"])
ExitStack can also be used to add cleanup callbacks dynamically:
from contextlib import ExitStack
with ExitStack() as stack:
stack.callback(print, "Cleanup 1 executed")
stack.callback(print, "Cleanup 2 executed")
print("Doing work...")
# Output (reverse order — LIFO):
# Doing work...
# Cleanup 2 executed
# Cleanup 1 executed
Summary #
- Context managers guarantee cleanup always runs — including when an exception occurs, through the always-called
__exit__mechanism.- The context manager protocol:
__enter__()runs when entering thewithblock,__exit__()runs when leaving — the__enter__return value is bound to theasvariable.return Truein__exit__swallows the exception — use it carefully; don’t hide errors that should be handled.@contextmanagerfor simple cases — write a context manager as a generator with thetry/yield/finallypattern without defining a class.- The value after
yieldin@contextmanageris bound to theasvariable in thewithblock.- Combine several context managers in one
withusing commas to avoid unnecessary nesting.ExitStackfor a dynamic number of context managers — ideal when you don’t know how many resources need managing when the code is written.