Python course menu
Python Context Managers: The with Statement, __enter__/__exit__ and contextlib
Learn Python context managers: why the with statement guarantees clean-up, how __enter__ and __exit__ work, writing your own with a class or @contextmanager, contextlib.suppress, and managing several resources at once.
On this page
Some resources must always be cleaned up after use: files must be closed, locks released, database transactions committed or rolled back. A context manager handles that automatically, and the with statement is how you use one. The clean-up runs even if an error happens in the middle.
The problem with manual clean-up#
f = open("hello.txt", "w")
try:
f.write("hi")
finally:
f.close()
print(f.closed)TrueThe try / finally guarantees the file closes, but it is easy to forget and noisy to write every time.
The with statement#
with open("hello.txt", "w") as f:
f.write("hi")
print(f.closed)TrueFiles are context managers. When the with block ends, normally or because of an exception, the file is closed automatically.
How it works: enter and exit#
Any object with these two methods can be used in with:
A timer as a context manager:
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
print("timer started")
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
print(f"timer stopped ({exc_type.__name__ if exc_type else 'no error'})")
return False
with Timer() as t:
total = sum(range(100000))
print(total, t.elapsed < 1)timer started
timer stopped (no error)
4999950000 TrueClean-up even on errors#
class Timer:
def __enter__(self):
print("enter")
return self
def __exit__(self, exc_type, exc, tb):
print("exit runs even on error:", exc_type.__name__)
return False
try:
with Timer():
1 / 0
except ZeroDivisionError:
print("error still raised")enter
exit runs even on error: ZeroDivisionError
error still raised__exit__ received the exception details and returned False, so the error continued on to the except block. Returning True would have silenced it; only do that deliberately.
The easy way: @contextmanager#
The contextlib module turns a simple generator into a context manager. Code before yield is the "enter" part; code after it is the "exit" part:
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
yield
print(f"</{name}>")
with tag("h1"):
print("Title")<h1>
Title
</h1>For guaranteed clean-up, put the yield inside try / finally:
import os
from contextlib import contextmanager
@contextmanager
def working_directory(path):
old = os.getcwd()
os.makedirs(path, exist_ok=True)
os.chdir(path)
try:
yield
finally:
os.chdir(old) # always switch back
start = os.path.basename(os.getcwd())
with working_directory("reports"):
print("inside:", os.path.basename(os.getcwd()))
print("back:", os.path.basename(os.getcwd()) == start)inside: reports
back: TrueHandy ready-made context managers#
contextlib.suppress ignores specific exceptions:
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("does-not-exist.txt")
print("no crash")no crashOthers you will meet: threading.Lock() for locks, database connections and transactions, tempfile.TemporaryDirectory(), and unittest.mock.patch in tests.
Several resources at once#
with open("a.txt", "w") as src, open("b.txt", "w") as dst:
src.write("A")
dst.write("B")
print(open("a.txt").read() + open("b.txt").read())ABBoth files are closed when the block ends, in reverse order.
Common mistakes#
Practice#
Practice exercise
Write a transaction(account) context manager for a dictionary like {"balance": 1000}. If the block raises an exception, restore the balance it had before the block, print rolled back, and re-raise the error. Withdraw 300 successfully, then try an overdraft of 5,000.
Show hint
Take a copy (dict(account)) before yield. In except, restore it with clear() and update(), then raise.
Show solution
from contextlib import contextmanager
@contextmanager
def transaction(account):
snapshot = dict(account)
try:
yield account
except Exception:
account.clear()
account.update(snapshot)
print("rolled back")
raise
account = {"balance": 1000}
with transaction(account) as acc:
acc["balance"] -= 300
print(account)
try:
with transaction(account) as acc:
acc["balance"] -= 5000
if acc["balance"] < 0:
raise ValueError("overdrawn")
except ValueError as e:
print("error:", e)
print(account){'balance': 700}
rolled back
error: overdrawn
{'balance': 700}This is the same idea databases use for SQL transactions: all or nothing.
Key takeaway#
Finished this lesson?