Python Custom Context Managers
Table of Contents + β
In the last lesson you learned about Closures. An inner function remembers values from the function around it. Now we use that same idea of remembering and cleaning up, but for whole blocks of code. You have already used with open(...) to work with files. The file closes itself when the block ends. It closes even if something goes wrong. The nice part is you can give your own objects that same behavior. That is what a custom context manager is.
π€ Why Build Your Own Context Manager?
Picture this. You open something. You use it. Then you must close it. Maybe it is a database connection. Maybe a network socket. Maybe a lock or a timer. The pain is the closing step. People forget it. Or an error jumps over it and the cleanup never runs.
Here is the messy version most people write first.
import time
start = time.perf_counter()total = sum(range(1_000_000)) # the work we want to measureend = time.perf_counter()print(f"Took {end - start:.4f} seconds")That works. But the setup and the cleanup are loose lines floating around your real work. If an error happens between them, the final print never runs. A context manager bundles the βbeforeβ step and the βafterβ step into one object. And Python promises the βafterβ step always runs.
The deal is simple.
- Setup happens when the
withblock starts. - Your code runs inside the block.
- Cleanup happens when the block ends. It runs even if an error was raised.
π§© How the with Statement Works
Think of a context manager like checking into a hotel room. When you arrive, the front desk hands you a key. That is the setup. You stay and use the room. When you leave, they take the key and clean the room. That is the cleanup. You do not have to remember to clean it yourself. It just happens on the way out.
For Python to do this with your object, the object needs two special methods.
| Method | When it runs | Its job |
|---|---|---|
__enter__ | The moment the with block starts | Do setup, then return whatever the block should use |
__exit__ | The moment the block ends (normally or by error) | Do cleanup |
When you write with thing as x:, Python calls thing.__enter__() and puts its return value into x. When the block finishes, Python calls thing.__exit__(...). This pair of methods is called the context manager protocol.
π οΈ The Class Way: __enter__ and __exit__
Letβs build a timer. It starts counting when the block begins. It prints how long the block took when the block ends. First the full code, then we walk through it.
import time
class Timer: def __enter__(self): self.start = time.perf_counter() # setup: remember the start time return self # this becomes the "as" value
def __exit__(self, exc_type, exc_value, traceback): end = time.perf_counter() elapsed = end - self.start print(f"Block took {elapsed:.4f} seconds") # returning None (no return) means: do not hide any error
with Timer(): total = sum(range(1_000_000)) print(f"Sum is {total}")Run it and you get something like this.
Output
Sum is 499999500000Block took 0.0123 secondsNow the walkthrough, line by line.
__enter__runs first. It saves the start time onself, then returnsself. The returned value is whataswould catch if you wrotewith Timer() as t:.- The block runs your real work, the
sum. __exit__runs last, and it always runs. It measures the end time and prints how long it took.
Now look at the three odd arguments on __exit__. They are exc_type, exc_value, and traceback. These tell you whether the block ended because of an error.
- If the block finished normally, all three are
None. - If an error was raised inside the block, they hold the error type, the error object, and where it happened.
The key point is that the cleanup still runs when there is an error. Watch.
with Timer(): print("starting work") raise ValueError("something broke") print("this line never runs")We raise an error, but the timer still prints its line on the way out.
Output
starting workBlock took 0.0001 secondsTraceback (most recent call last): File "demo.py", line 18, in <module> raise ValueError("something broke")ValueError: something brokeSee how βBlock took β¦β printed before the traceback? That is the promise. __exit__ always runs. The error then continues on its way, because we did not stop it.
Note
If __exit__ returns True, Python treats the error as handled and swallows it. So it does not crash your program. If it returns None or False, the error keeps going as normal. Returning True by accident hides real bugs. So only do it on purpose.
π A Real Resource: Always Closing
A timer is nice for learning. But the everyday use is opening a resource and making sure it closes. Imagine a simple connection object that must be closed when you finish.
class Connection: def __init__(self, name): self.name = name
def __enter__(self): print(f"Opening connection to {self.name}") return self # hand the connection to the block
def query(self, sql): print(f"Running: {sql}")
def __exit__(self, exc_type, exc_value, traceback): print(f"Closing connection to {self.name}")
with Connection("users-db") as conn: conn.query("SELECT * FROM users")The connection opens at the start. You use it inside. And it closes itself at the end.
Output
Opening connection to users-dbRunning: SELECT * FROM usersClosing connection to users-dbYou never wrote a manual close() call, yet the connection closed. And it would still close even if query raised an error. That is the whole point. It is cleanup you cannot forget.
β¨ The Easy Way: contextlib.contextmanager
Writing a whole class with two special methods feels like a lot for something small. Python gives you a shortcut. With the contextmanager decorator from the contextlib module, you write one normal function and use yield to split setup from cleanup.
Here is the timer again, the short way.
import timefrom contextlib import contextmanager
@contextmanagerdef timer(): start = time.perf_counter() # everything before yield = setup yield # the with block runs right here end = time.perf_counter() # everything after yield = cleanup print(f"Block took {end - start:.4f} seconds")
with timer(): total = sum(range(1_000_000)) print(f"Sum is {total}")The output is the same as the class version.
Output
Sum is 499999500000Block took 0.0119 secondsHere is the way to read this.
- The code before
yieldis your__enter__. - The
yielditself is where yourwithblock runs. - The code after
yieldis your__exit__.
If you want an as value, you yield it. Here is the connection example written the short way, handing back a value.
from contextlib import contextmanager
@contextmanagerdef connection(name): print(f"Opening connection to {name}") conn = {"name": name} # pretend this is a real connection yield conn # this becomes the "as" value print(f"Closing connection to {name}")
with connection("users-db") as conn: print(f"Using {conn['name']}")Output
Opening connection to users-dbUsing users-dbClosing connection to users-dbThere is one catch with this short style. If an error happens in the block, it pops out at the yield line. So if your cleanup must run no matter what, wrap the yield in try/finally. The finally block always runs, error or not.
from contextlib import contextmanager
@contextmanagerdef connection(name): print(f"Opening connection to {name}") try: yield {"name": name} # block runs here finally: print(f"Closing connection to {name}") # always runs
with connection("users-db") as conn: raise RuntimeError("query failed")The connection still closes before the error continues.
Output
Opening connection to users-dbClosing connection to users-dbTraceback (most recent call last): File "demo.py", line 13, in <module> raise RuntimeError("query failed")RuntimeError: query failedβ οΈ Common Mistakes
A few traps catch people the first time they build their own.
- Forgetting to return something useful from
__enter__. If you wantwith X() as y:to give you the object, you mustreturn self. Forget it andybecomesNone.
# β Avoid: returns nothing, so "as f" is Nonedef __enter__(self): self.file = open("data.txt")
# β
Good: return the thing the block should usedef __enter__(self): self.file = open("data.txt") return self.file- Putting cleanup after
yieldwithouttry/finally. In the decorator style, an error in the block skips any plain line afteryield. Your cleanup quietly does not run.
# β Avoid: if the block errors, cleanup is skipped@contextmanagerdef session(): s = open_session() yield s s.close() # never runs if the block raised
# β
Good: finally guarantees cleanup@contextmanagerdef session(): s = open_session() try: yield s finally: s.close()- Returning
Truefrom__exit__by habit. That hides every error raised in the block. Only returnTruewhen you truly mean βI handled this error, do not crash.β
β Best Practices
Small habits keep your context managers clean and predictable.
- Reach for the
@contextmanagerdecorator for short, simple setup and cleanup. Use a full class when the object also needs other methods or holds a lot of state. - Always guard the cleanup. Use
try/finallyin the decorator style. And remember a class__exit__already runs on errors. - Keep the setup and cleanup small and fast. A context manager is about managing a resource, not doing heavy work.
- Name the function or class after the resource it manages, like
timer,connection, ortemp_directory. Then thewithline reads like plain English.
π§© What Youβve Learned
- β
A context manager runs setup when a
withblock starts and cleanup when it ends. The cleanup runs even if an error is raised. - β
The class way needs
__enter__(setup, returns theasvalue) and__exit__(cleanup, gets error info). - β
__exit__returningTrueswallows the error. ReturningNonelets it continue. - β
The
@contextmanagerdecorator lets you write one function. Code beforeyieldis setup and code after is cleanup. - β
Wrap the
yieldintry/finallyso cleanup runs even when the block errors.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
In a class-based context manager, which method runs when the with block ends?
Why: __exit__ runs when the block ends, whether it finished normally or an error was raised.
- 2
In a @contextmanager function, where does the with block actually run?
Why: Code before yield is setup, the block runs at the yield, and code after yield is cleanup.
- 3
What does the __enter__ return value become?
Why: Whatever __enter__ returns is bound to the name after 'as' in the with statement.
- 4
Why wrap the yield in try/finally inside a @contextmanager function?
Why: Without finally, an error in the block skips any plain line after yield, so cleanup would not run.
π Whatβs Next?
Now you can wrap setup and cleanup around your own objects. Next letβs look at a cleaner way to build classes that mostly just hold data. You get to skip writing all the boilerplate yourself.