What are context managers and when should you use them? Let's explore.
You've probably seen this pattern before:
with open("data.txt", "r") as f:
content = f.read()The with statement here is using a context
manager. A context manager is any object that implements two
dunder methods: __enter__ and __exit__. When
Python hits the with block, it calls __enter__
to set things up and __exit__ to tear things down, even if
an exception is raised inside the block.
So in the example above, open() returns a file object
that acts as a context manager. After the with block exits
(normally or due to an error), the file is automatically closed. No need
to call f.close() yourself.
Context managers shine whenever you have a setup and teardown pattern. The classic examples are:
The key insight is that the teardown must happen
regardless of whether the code inside succeeds or fails. Without a
context manager, you'd need a try/finally block:
f = open("data.txt", "r")
try:
content = f.read()
finally:
f.close()This works, but it's verbose and easy to forget. A context manager handles this for you automatically and keeps your code clean.
For simple one-off operations, a context manager can feel like overkill. If you're just reading a small file and you know it won't fail, the mental overhead of thinking about resource management may not be worth it.
Writing a custom context manager also requires a bit of
boilerplate. You either need to implement a class with
__enter__ and __exit__, or use the
contextlib.contextmanager decorator. Neither is difficult,
but it's more code than doing things manually. If you only need the
setup/teardown logic in one place, it might not be worth extracting into
a context manager.
That said, in most real-world code, the benefits far outweigh the costs. Resource leaks, especially for unclosed database connections, are notoriously hard to debug. Context managers make them nearly impossible to introduce accidentally.
In my previous post on mssql-python, I was manually commiting and closing the connection. In reality, here's how you would do it:
from collections.abc import Generator
from contextlib import contextmanager
import mssql_python as mssql
connection_string = "SERVER=<server>;DATABASE=<db>;Authentication=ActiveDirectoryDefault;Encrypt=yes;"
@contextmanager
def sql_connection(connection_string: str, **kwargs) -> Generator[mssql.Connection]:
conn = mssql.connect(connection_string, **kwargs)
try:
yield conn
except mssql.exceptions.Exception as e:
# Handle specific exceptions and use better logging in production code
print(e)
c.rollback()
finally:
conn.commit()
conn.close()
with sql_connection(connection_string) as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
rows = cursor.fetchall()When the with block exits, the connection is
automatically committed and closed. If an exception occurs, the
transaction is rolled back.
Let's say you want to time how long a block of code takes. You can
write a class with __enter__ and __exit__:
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.4f}s")
return # return True if you want to suppress any errors
with Timer():
time.sleep(1.5)
> Elapsed: 1.5003sThe __exit__ method receives information about any
exception that occurred.
Context managers are one of those Python features that make your code
both safer and easier to read. Whenever you find yourself writing a
try/finally block to ensure cleanup happens, that's a
signal to reach for a context manager instead. For simple cases,
contextlib.contextmanager is your best friend. It lets you
define the setup and teardown in a single function without any class
boilerplate. For more complex cases where you need to inspect or handle
exceptions at teardown, a class with __enter__ and
__exit__ gives you full control.