Python Tutorial: Context Managers
In keeping with the theme from the last article, let’s focus on another Python language feature which is arguably even more useful for writing correct code.
Python’s context managers, introduced through the with
statement, are often showcased with file operations but their capabilities reach far deeper. Under the hood, a context manager is any object implementing the __enter__
and __exit__
methods (__aenter__
and __aexit__
for async-await variants). This design abstracts resource management, enabling predictable control over setup and teardown logic without relying on the garbage collector for cleanup.
with open("example.txt") as f:
data = f.read()
Under the hood basically is:
f = open("example.txt")
try:
data = f.read()
finally:
f.close()
This pattern generalizes to any resource - database connections, locks, temporary environments, even mocking in tests.
The simplest way to write a context manager, one that I use most often, is facilitated by contextlib
.
import os
from contextlib import contextmanager
@contextmanager
def temporary_env(var: str, value: str):
old = os.environ.get(var)
os.environ[var] = value
try:
yield
finally:
if old is None:
del os.environ[var]
else:
os.environ[var] = old
This one simple decorator (asynccontextmanager
for async contexts) transforms any function into a context manager. All it needs to do is to use the try-except-finally
statement internally and yield something - this becomes the lifetime of the context.
For more complicated stuff (especially if you need state), you can define the previously mentioned methods in a class:
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.end = time.perf_counter()
self.interval = self.end - self.start
print(f"Took {self.interval:.3f}s")
which can be used directly like so
with Timer():
print("This is a timed block of code.")
or you can define a function which returns such context manager (resembling the built-in open()
function)
def time_this():
return Timer()
with time_this() as timer:
print(f"Timer started at: {timer.start:.3f}s")
print("This is a timed block of code.")
You can also enter multiple context managers using a single with
statement, which is expecially useful when using patch()
from the mocking module.
with time_this() as timer1, time_this() as timer2:
pass
Context managers encapsulate temporal concerns - what must happen before and after an action. They’re foundational to Pythonic design, providing clarity, composability, and safety.
I also like how explicitly they communicate the scope of a resources’s lifetime. Context managers can be used to help with otherwise fuzzy scoping rules and bring Python closer to static languages with explicit destructors, when it comes to object lifetimes.