§14  Context Managers
Article 14 Python Programming Series

Context Managers in Python: A Complete Beginner's Guide

You've been writing with open(...) as f: for a while now. It's the "professional way" to work with files, and it closes the file for you. But what does with actually do? It looks like special syntax just for files, but it isn't. This guide opens it up and shows you, in plain English and one small step at a time, how with works and how to build your own.

What with really does
__exit__ and errors
@contextmanager made simple
Rollbacks and safe file writes

Here are the three ways to close a file that you may have seen before:

python
f = open("data.txt")      # 1. remember to call f.close()
...

try:                      # 2. try/finally
    f = open("data.txt")
finally:
    f.close()

with open("data.txt") as f:   # 3. the professional way
    ...

The third one is what everyone uses. The secret is that with works with any object that follows a small set of rules, not just files. A file just happens to be the first such object you met. Objects that follow these rules are called context managers, and once you understand them you can use the same clean pattern for database connections, timers, temporary settings, safe file writing and much more.

This is also where generators and decorators come together: the shortest way to write your own with helper is a generator with a decorator on top.

Here is what we will cover:

  • Why cleanup is harder than it looks
  • The two methods behind with: __enter__ and __exit__
  • How to write your own context manager as a class
  • What as gives you, and what __exit__ sees when the block crashes
  • A timer for any block of code
  • @contextlib.contextmanager, the short way to write one
  • The contextlib tools you'll use: suppress, closing, redirect_stdout
  • Using several managers in one with
  • Real patterns: rollbacks, a temporary working directory, safe file writes
  • ExitStack, for when you don't know in advance how many managers you need

Setting Up

We'll keep our practice files in their own folder, so they don't clutter your working directory. This cell creates a small sales file with four regions:

python
import os
from pathlib import Path

os.makedirs("context_demo", exist_ok=True)

with open("context_demo/sales.csv", "w", encoding="utf-8") as f:
    f.write("region,units\n")
    f.write("north,120\n")
    f.write("south,95\n")
    f.write("east,140\n")
    f.write("west,88\n")

print("Sample files ready.")
Sample files ready.

Part 1: The Problem, Cleanup That Has to Happen

When you open a file, the operating system gives your program a file handle (also called a file descriptor). Think of it as a numbered ticket that says "this program is currently using this file". A program can only hold a limited number of these tickets at once, which is why you must give each one back with close().

Analogy: a coat-check counter has a limited number of hooks. Every time you check in a coat, you get a ticket. If people keep checking in coats and never collect them, the counter eventually runs out of hooks and has to turn new people away.

Here's what happens when something goes wrong before you get the chance to close the file:

python
f = open("context_demo/sales.csv", encoding="utf-8")

try:
    f.readline()                    # skip the header row
    units = int("not a number")     # a bad row in the data, so this raises
    f.close()                       # never reached
except ValueError as e:
    print("Error:", e)

print("Is the file still open?", not f.closed)
f.close()                           # clean up so the rest of the notebook is tidy
Error: invalid literal for int() with base 10: 'not a number'
Is the file still open? True

The close() call was right there in the code. It just never ran, because the error jumped straight past it to the except block. The file stayed open.

One file left open on your laptop does no harm. But a web server that leaves one open for every request eventually runs out of handles and stops accepting connections. That's how small cleanup mistakes turn into real outages.

The finally Fix

A finally block always runs, whether the code above it succeeded or crashed:

python
f = open("context_demo/sales.csv", encoding="utf-8")

try:
    f.readline()
    units = int("not a number")
except ValueError as e:
    print("Error:", e)
finally:
    f.close()                       # runs no matter what

print("Is the file still open?", not f.closed)
Error: invalid literal for int() with base 10: 'not a number'
Is the file still open? False

That works, but it's a lot of extra lines around one line of real work. You also have to remember it every single time. And the f.close() you must remember sits far away from the open() that made it necessary.

Analogy: it's like leaving yourself a sticky note saying "turn off the stove" on the front door. It might work, but the reminder is far from the stove, and one day you'll forget to write the note.

The with Fix

with moves the cleanup into the file object itself. Here's the same crash inside a with block:

python
try:
    with open("context_demo/sales.csv", encoding="utf-8") as f:
        f.readline()
        units = int("not a number")     # the same crash as before
except ValueError as e:
    print("Error:", e)

print("Is the file still open?", not f.closed)
Error: invalid literal for int() with base 10: 'not a number'
Is the file still open? False

The file closed on its own, even though the block crashed. You didn't have to remember anything.

Analogy: with is like a hotel room with an automatic check-out. However you leave (calmly at the end of your stay, or in a rush because the fire alarm went off), the hotel checks you out and takes back the key card. You can't forget, because it isn't your job anymore.

This Pattern Is Everywhere

Nothing about this pattern is specific to files. It shows up anywhere you take something and have to give it back:

You acquire You must release If you forget
An open file close() Leaked descriptors; unflushed writes
A database connection close() The connection pool drains and the app stalls
A lock release() Every other thread waits forever
A changed setting Restore the old value The rest of the program silently misbehaves
A started transaction commit() or rollback() Half-written data
A temporary directory Delete it Disk fills up

Every row has a setup step and a cleanup step, where the cleanup has to happen even if the part in the middle crashes. That's the job of a context manager.


Part 2: What with Actually Does

with isn't file syntax. Any object that has two special methods, __enter__ and __exit__, can be used in a with statement. Python calls a set of methods like this a protocol, and these two make up the context manager protocol.

Analogy: a protocol is like the shape of a power socket. The socket doesn't care what brand of device you plug in. As long as the plug has the right shape (here: an __enter__ and an __exit__ method), it fits.

These are dunder methods (short for "double underscore") that Python calls for you, just like __init__, __iter__ and __next__.

This:

python
with open("data.txt") as f:
    body

is roughly a shorter way to write this:

python
manager = open("data.txt")
f = manager.__enter__()
try:
    body
finally:
    manager.__exit__(exception_type, exception_value, traceback)

(exception_type, exception_value and traceback describe the error, if there was one. Part 5 looks at them.)

   with open("data.txt") as f:          what Python does
       body
                                         1. manager = open("data.txt")
                                         2. f = manager.__enter__()      ← SETUP   ("check in")
                                         3. run the body                 ← your code
                                         4. manager.__exit__(...)        ← CLEANUP ("check out")
                                            ALWAYS runs, even if step 3 crashed

So the try/finally we wrote by hand didn't disappear. Python writes it for you, and the object supplies the cleanup.

First, let's check that a file object really has those two methods:

python
f = open("context_demo/sales.csv", encoding="utf-8")

print("has __enter__:", hasattr(f, "__enter__"))
print("has __exit__ :", hasattr(f, "__exit__"))

f.close()
has __enter__: True
has __exit__ : True

We can even call the two methods ourselves, with no with statement at all:

python
f = open("context_demo/sales.csv", encoding="utf-8")

same_file = f.__enter__()                       # what 'as' would have bound
print("__enter__ returned the file itself:", same_file is f)
print("first line:", same_file.readline().strip())

f.__exit__(None, None, None)                    # the three arguments come later
print("closed:", f.closed)
__enter__ returned the file itself: True
first line: region,units
closed: True

__enter__ returned the file itself (so same_file is f is True), and __exit__ closed it. Never write that in real code. It's only here to prove that with is nothing more than ordinary method calls.

If an object doesn't have those methods, with tells you plainly:

python
try:
    with 42 as number:              # deliberately wrong: 42 is not a context manager
        print(number)
except TypeError as e:
    print("TypeError:", e)
TypeError: 'int' object does not support the context manager protocol

This error means the object has no __enter__/__exit__, which usually means you passed the wrong thing. A common cause is giving with a file path (a string) when you meant open(path).


Part 3: Writing Your Own Context Manager

You only need two methods. The simplest useful one announces when a block starts and ends:

python
class Section:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"[start] {self.name}")
        return self                     # this is what 'as' binds

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"[end]   {self.name}")


with Section("loading data"):
    print("   ...reading rows...")
    print("   ...cleaning rows...")

print("back outside the block")
[start] loading data
   ...reading rows...
   ...cleaning rows...
[end]   loading data
back outside the block

Read the output from top to bottom and you can see the order:

  1. __enter__ runs ([start] loading data).
  2. The body of the with block runs (the two ... lines).
  3. __exit__ runs ([end] loading data).
  4. The code after the block runs.

Analogy: like the curtain at a theatre. The curtain opens (__enter__), the play happens (the body), and the curtain closes (__exit__). You never have to remind the curtain to close.

A Database Connection That Always Closes

Real programs often talk to a database. You connect, run some queries, and then must close the connection. Here's a pretend database class with that guarantee built in:

python
class Database:
    # A stand-in for a real driver such as sqlite3 or psycopg2.

    def __init__(self, name):
        self.name = name
        self.queries = []

    def run(self, query):
        self.queries.append(query)
        print(f"[DB] running: {query}")

    def __enter__(self):
        print(f"[DB] connected to {self.name!r}")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"[DB] closed after {len(self.queries)} queries")


with Database("analytics") as db:
    db.run("SELECT count(*) FROM users")
    db.run("SELECT * FROM orders LIMIT 10")
[DB] connected to 'analytics'
[DB] running: SELECT count(*) FROM users
[DB] running: SELECT * FROM orders LIMIT 10
[DB] closed after 2 queries

The caller writes one with line and can't get the cleanup wrong. They don't even need to know how closing works.

Analogy: a phone that automatically hangs up when you put it down. You just talk; the hanging up takes care of itself.

The Real Test: What Happens When the Block Crashes?

python
try:
    with Database("analytics") as db:
        db.run("SELECT * FROM users")
        db.run("UPDATE users SET last_seen = now()")
        raise RuntimeError("network dropped mid-query")
except RuntimeError as e:
    print("caught outside the block:", e)
[DB] connected to 'analytics'
[DB] running: SELECT * FROM users
[DB] running: UPDATE users SET last_seen = now()
[DB] closed after 2 queries
caught outside the block: network dropped mid-query

Look at the order. [DB] closed printed first, then our except ran. The connection closed on the way out, and the error still reached the caller, so nothing was hidden.

That's the default, and it's the right one. A context manager's job is to clean up. It doesn't get to decide that your error didn't matter.


Part 4: What as Gives You

as name gives you whatever __enter__ returns. Not the manager, and not the object you passed in. Most managers return self, which is why it usually looks like the same object.

Analogy: when you check in at a hotel, the receptionist hands you a key card. What you get "as" a result of checking in is whatever the receptionist chooses to hand you: usually the key card, but it could be a welcome pack, or nothing at all.

Forget that return and you get a confusing bug:

python
class Broken:
    def __enter__(self):
        print("opening")
        # oops: no return statement

    def __exit__(self, exc_type, exc_value, traceback):
        print("closing")


with Broken() as thing:
    print("thing is:", thing)
opening
thing is: None
closing

__enter__ returned None, as every function without a return does, so thing is None. Nothing has gone wrong yet. The error shows up at the next line that tries to use it:

python
try:
    with Broken() as thing:
        thing.run("SELECT 1")       # thing is None, so this fails
except AttributeError as e:
    print("AttributeError:", e)
opening
closing
AttributeError: 'NoneType' object has no attribute 'run'

If you see 'NoneType' object has no attribute ... inside a with block, check for a missing return self first.

__enter__ Can Return Anything Useful

Returning self is a habit, not a rule. __enter__ can hand the block anything useful. This one hands over an empty list to collect things into, then reports on it afterwards:

python
class Collected:
    def __enter__(self):
        self.items = []
        return self.items               # the block gets the list, not the manager

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"collected {len(self.items)} items: {self.items}")


with Collected() as bucket:
    bucket.append("north")
    bucket.append("south")
    bucket.append("east")
collected 3 items: ['north', 'south', 'east']

The block worked directly with the list (bucket), and when the block ended, __exit__ reported what was collected.


Part 5: __exit__ and Errors

We've been writing __exit__(self, exc_type, exc_value, traceback) and ignoring the three arguments. Here's what they hold:

  • When the block finishes without an error, all three are None.
  • When the block raises an error, they are the error's class (like ZeroDivisionError), the error object itself (which holds the message), and its traceback (the record of where it happened). Python's sys.exc_info() gives you the same three things.

Analogy: think of a lifeguard at the exit of a pool. When each swimmer leaves, the lifeguard notes either "swim went fine" (three Nones) or "this swimmer got a cramp, here's what happened and where" (the three error details).

python
class Watcher:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            print("__exit__: block finished cleanly")
        else:
            print(f"__exit__: block raised {exc_type.__name__} -> {exc_value}")


with Watcher():
    print("doing fine")

print()

try:
    with Watcher():
        total = 100 / 0
except ZeroDivisionError:
    print("...and it still reached us out here")
doing fine
__exit__: block finished cleanly

__exit__: block raised ZeroDivisionError -> division by zero
...and it still reached us out here

So __exit__ gets to see the error. That's what makes rollbacks possible: the manager can tell "the block worked, keep the changes" apart from "the block failed, undo everything". We'll build exactly that in Part 11.

Returning True Swallows the Error

__exit__ has one more power. If it returns True, Python treats the error as handled, and the error never leaves the with block:

python
class Swallow:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("__exit__ saw:", exc_type.__name__)
        return True                     # "I handled it"


with Swallow():
    print("about to fail")
    result = 100 / 0
    print("this line never runs")

print("we get here: the exception never escaped the block")
about to fail
__exit__ saw: ZeroDivisionError
we get here: the exception never escaped the block

Notice that "this line never runs" didn't print. The error still ended the block at the line that failed. Returning True only stopped the error from travelling any further.

   the with block raises an error
          │
          ▼
   __exit__(exc_type, exc_value, traceback) runs
          │
          ├── returns True (or anything truthy)  →  error is SWALLOWED, program carries on after the block
          │
          └── returns False / None / nothing     →  error CONTINUES to the caller (the normal, safe default)

Be careful with this. Swallow doesn't only swallow the error you had in mind. It swallows every error, including ones you never thought about:

python
with Swallow():
    total = int("120") + int("nrth")    # a typo in a region name
    print("total:", total)

print("no crash, no total, no clue anything went wrong")
__exit__ saw: ValueError
no crash, no total, no clue anything went wrong

A typo quietly produced nothing at all. If that block had been working out a monthly revenue figure, the report would be missing a number and nobody would know why.

Analogy: a manager who hides every complaint from the boss, not just the trivial ones. Things seem calm, until something serious goes unnoticed.

Swallowing Only One Kind of Error

A safer version swallows one kind of error and lets the rest through. Its __exit__ returns True for "stop this error here" and False for "let it continue".

issubclass(exc_type, FileNotFoundError) asks "is this error a FileNotFoundError, or one of its child error types?":

python
class IgnoreMissingFile:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            return False
        if issubclass(exc_type, FileNotFoundError):
            print(f"ignoring: {exc_value.filename} was already gone")
            return True                 # handled
        return False                    # anything else goes through


with IgnoreMissingFile():
    os.remove("context_demo/never_existed.txt")

print("cleanup finished\n")

try:
    with IgnoreMissingFile():
        os.remove(12345)                # a bug: 12345 is not a filename
except TypeError as e:
    print("TypeError got through, as it should:", e)
ignoring: context_demo/never_existed.txt was already gone
cleanup finished

TypeError got through, as it should: remove: path should be string, bytes or os.PathLike, not int
  • The first block tried to delete a file that doesn't exist. That's a FileNotFoundError, so it was quietly ignored. (For a cleanup task, "the file was already gone" is perfectly fine.)
  • The second block had a real bug (passing a number instead of a filename). That's a TypeError, so __exit__ returned False and the error got through, as it should.

The Accidental Swallow: Truthy and Falsy Values

Here's the catch: Python doesn't only check for the exact value True. It treats lots of values as true or false, the same way an if does:

  • Non-zero numbers and non-empty strings count as true.
  • 0, None and empty things (like "" or []) count as false.

People call these truthy and falsy values. bool() shows how Python sees each one:

python
for value in [True, 2, "done", False, 0, None, ""]:
    print(f"{value!r:8} counts as {bool(value)}")
True     counts as True
2        counts as True
'done'   counts as True
False    counts as False
0        counts as False
None     counts as False
''       counts as False

So any truthy value returned from __exit__ swallows the error. That makes this bug easy to write without noticing. The __exit__ below ends with a return that was only meant to report a count:

python
class Cleanup:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        removed = ["batch_1.tmp", "batch_2.tmp"]
        print(f"cleaned up {len(removed)} temp files")
        return len(removed)             # meant as a count, but 2 is truthy!


with Cleanup():
    1 / 0

print("the ZeroDivisionError vanished, and nothing said so")
cleaned up 2 temp files
the ZeroDivisionError vanished, and nothing said so

return len(removed) returned 2, and 2 is truthy, so Python decided the error was handled. A ZeroDivisionError vanished without a trace.

The rule: end an __exit__ with return True, return False, or no return at all. Anything else risks swallowing errors by accident. If you don't want to suppress anything, just don't return a value: you get None, which is falsy.


Part 6: A Timer for Blocks of Code

A decorator like @execution_timer can time a whole function. But if a function does three things and only one of them is slow, the decorator can't tell you which.

A context manager times a block, and a block can be as big or as small as you like:

python
import time

class Timer:
    def __init__(self, label):
        self.label = label

    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.elapsed = time.perf_counter() - self.start
        print(f"{self.label}: {self.elapsed:.1f}s")


with Timer("loading sales data"):
    time.sleep(0.5)                     # stand-in for the real work
loading sales data: 0.5s

time.perf_counter() is a stopwatch made for measuring short durations accurately. __enter__ starts it, and __exit__ stops it and prints the time.

Here's what the decorator couldn't do: time the separate parts of one function:

python
def build_report(rows):
    with Timer("  parse    "):
        time.sleep(0.2)
        parsed = [row.split(",") for row in rows]

    with Timer("  aggregate"):
        time.sleep(0.5)                 # the slow step
        total = sum(int(cells[1]) for cells in parsed)

    return total


with open("context_demo/sales.csv", encoding="utf-8") as f:
    next(f)                             # skip the header row
    rows = [line.strip() for line in f]

with Timer("build_report"):
    print("total units:", build_report(rows))
  parse    : 0.2s
  aggregate: 0.5s
total units: 443
build_report: 0.7s

The output works like a small profiler (a tool that shows where time is spent), and it shows straight away that aggregate is the slow step. Notice that the timers can even be nested: the outer build_report timer includes both inner timers (0.2 + 0.5 = 0.7 seconds).

Analogy: a decorator is like timing a whole race. A context manager is like a stopwatch with a lap button: you can time each lap separately and see exactly which one was slow.

@execution_timer (decorator) with Timer(...) (context manager)
Times a whole function any block of code
Applied once, at the definition anywhere, as often as you like
Needs the code to be a function anything
Answers "which function is slow?" "which part of this function is slow?"

You'll use both, because they answer different questions.

A Class-Based Manager Can Be Reused

A class-based manager can be used again and again. You can enter the same Timer object several times, and it keeps self.elapsed afterwards for you to read:

python
stage = Timer("reusable")

with stage:
    time.sleep(0.2)

with stage:
    time.sleep(0.5)

print(f"the instance still remembers the last run: {stage.elapsed:.1f}s")
reusable: 0.2s
reusable: 0.5s
the instance still remembers the last run: 0.5s

Part 7: @contextlib.contextmanager, Generators Plus Decorators

Writing a whole class for every setup-and-cleanup pair is a lot of typing. The standard library has a shortcut, and it's built from two ideas you already know:

  • Generators: a generator runs up to yield, pauses, and later carries on from where it stopped.
  • Decorators: a decorator wraps a function to give it new behaviour.

contextlib.contextmanager is a decorator that turns a generator function into a context manager:

  • Everything before the yield does the job of __enter__.
  • The with block runs at the yield, while the generator is paused.
  • Everything after the yield does the job of __exit__.

Analogy: a sandwich. The top slice of bread is the setup, the filling is your with block, and the bottom slice is the cleanup. yield marks where the filling goes.

   @contextmanager
   def section(name):
       print("[start]")     ← top bread:    runs when the with block is entered  (__enter__)
       yield                ← the filling:  the with block runs HERE, generator paused
       print("[end]")       ← bottom bread: runs when the with block ends        (__exit__)
python
from contextlib import contextmanager


@contextmanager
def section(name):
    print(f"[start] {name}")            # __enter__
    yield                               # the with-block runs right here
    print(f"[end]   {name}")            # __exit__


with section("loading data"):
    print("   ...reading rows...")

print("back outside the block")
[start] loading data
   ...reading rows...
[end]   loading data
back outside the block

Same output as the Section class from Part 3, in about half the code.

Whatever you yield is what as gives you. It's the generator's version of return self:

python
@contextmanager
def collected():
    items = []
    yield items                         # handed to 'as'
    print(f"collected {len(items)} items: {items}")


with collected() as bucket:
    bucket.append("north")
    bucket.append("south")
collected 2 items: ['north', 'south']

The Bug: Cleanup That Never Runs

There's a trap here that's easy to fall into. Look at this timer, which has no try anywhere:

python
@contextmanager
def timer(label):
    start = time.perf_counter()
    yield
    print(f"{label}: {time.perf_counter() - start:.1f}s")   # after the yield


with timer("happy path"):
    time.sleep(0.2)
happy path: 0.2s

That works. Now make the block fail:

python
try:
    with timer("crashing block"):
        time.sleep(0.2)
        raise RuntimeError("boom")
except RuntimeError as e:
    print("caught:", e)

print("...and notice there is no timing line above")
caught: boom
...and notice there is no timing line above

The cleanup never ran. There's no timing line at all.

Here's why. When the with block raises an error, Python sends that error into the generator at the yield, as if the yield line itself had raised it. (A paused generator can be woken up in more than one way: for example, by asking for the next value, or by having an error sent into it.) The error then flies straight out of the generator, so the lines after yield are skipped.

Analogy: it's like a shop where the "remember to lock up" step is written at the end of the closing checklist. If the fire alarm goes off halfway through the checklist, everyone runs out and nobody reaches the last line.

The class version never had this problem, because Python always calls __exit__. To get the same guarantee from a generator, put the yield inside a try/finally yourself:

python
@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:                            # runs on the way out, always
        print(f"{label}: {time.perf_counter() - start:.1f}s")


try:
    with timer("crashing block"):
        time.sleep(0.2)
        raise RuntimeError("boom")
except RuntimeError as e:
    print("caught:", e)
crashing block: 0.2s
caught: boom

Now the timing line printed even though the block crashed, and the error still reached our except.

If the cleanup has to happen, put the yield inside a try/finally. Make that your default way of writing these.

Handling the Error Inside a Generator Manager

A try/except around the yield is the generator's version of "__exit__ returned True". Catch the error, don't raise it again, and it stops there:

python
@contextmanager
def ignore_missing_file():
    try:
        yield
    except FileNotFoundError as e:
        print(f"ignoring: {e.filename} was already gone")


with ignore_missing_file():
    os.remove("context_demo/never_existed.txt")

print("cleanup finished")
ignoring: context_demo/never_existed.txt was already gone
cleanup finished

A @contextmanager Object Only Works Once

One more thing: an object made by a @contextmanager function only works once. Entering it runs the generator up to the yield. A generator can't be restarted, so there's nothing left to run a second time:

python
cm = section("used once")

with cm:
    print("   first use is fine")

try:
    with cm:
        print("   second use")
except AttributeError as e:
    print("second use failed:", e)
[start] used once
   first use is fine
[end]   used once
second use failed: '_GeneratorContextManager' object has no attribute 'args'

The message is hard to understand. It just means this manager has already used up its generator. The fix is to call the function again each time: write with section("..."): instead of saving it as cm = section("...") and reusing with cm:.

Analogy: a generator-based manager is like a single-use ticket. A class-based manager like Timer is like a season pass.


Part 8: Class or Generator?

Both give you a context manager. Pick based on what the block needs.

Use a class when Use @contextmanager when
The block needs methods on the object (db.run(...)) The block just needs setup and teardown
You want to reuse one instance repeatedly A fresh one per with is fine
You want state readable afterwards (stage.elapsed) Nothing survives the block
You want to subclass or extend it It is a one-off helper
The teardown logic is long It fits in a few lines

Most context managers you write yourself will be short helpers, so you'll mostly use @contextmanager. Most of the ones you use from libraries (files, database connections, locks) are classes, because they come with methods.


Part 9: The contextlib Toolkit

contextlib (a module in Python's standard library) has more in it than the decorator. Three of its tools are useful right now.

suppress: A Shorter try/except/pass

contextlib.suppress is a short way to ignore one kind of error, and now you can see what it really is: a context manager. This:

python
try:
    os.remove(path)
except FileNotFoundError:
    pass

becomes:

python
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("context_demo/does_not_exist.txt")

print("no crash: the file was already gone, which is fine")
no crash: the file was already gone, which is fine

It's shorter, and unlike a bare except: pass, it names exactly which error you're happy to ignore. You can list more than one: suppress(FileNotFoundError, PermissionError).

Keep the block to one statement. suppress doesn't skip the failing line and carry on. It ends the whole block, just like any other error would:

python
for name in ["temp_a.txt", "temp_b.txt"]:
    with open(f"context_demo/{name}", "w", encoding="utf-8") as f:
        f.write("scratch\n")

with suppress(FileNotFoundError):
    os.remove("context_demo/temp_a.txt")        # exists, so it is removed
    os.remove("context_demo/gone.txt")          # missing, so the block ends HERE
    os.remove("context_demo/temp_b.txt")        # never runs

print("temp_a.txt still there?", os.path.exists("context_demo/temp_a.txt"))
print("temp_b.txt still there?", os.path.exists("context_demo/temp_b.txt"))

os.remove("context_demo/temp_b.txt")            # clean up properly
temp_a.txt still there? False
temp_b.txt still there? True

temp_b.txt survived, because the line that would have deleted it never ran. It's easy to assume suppress works line by line, but it doesn't.

Analogy: a fire drill doesn't just skip one sentence of a meeting; it ends the whole meeting. Everything after the alarm is cancelled.

So use one statement per with suppress(...), or put the with inside a loop.

closing: For Objects with close() but No with Support

Lots of older libraries give you an object that has a close() method but doesn't support with. closing wraps it so it works with with anyway:

python
from contextlib import closing


class LegacyCursor:
    # An old-style object: it has close(), but no __enter__/__exit__.

    def fetch(self):
        return [("north", 120), ("south", 95)]

    def close(self):
        print("[cursor] closed")


with closing(LegacyCursor()) as cursor:
    print("rows:", cursor.fetch())
rows: [('north', 120), ('south', 95)]
[cursor] closed

closing(obj) is a context manager whose __exit__ calls obj.close(). That's all it does.

Analogy: a travel adapter. Your old device has the wrong plug for this socket; the adapter makes it fit.

redirect_stdout: Catching What a Function Prints

Sometimes a library prints things you'd rather capture: to save in a log, to hide from the user, or to check in a test. redirect_stdout sends everything print() writes somewhere else while the block runs. Here it goes into io.StringIO(), which acts like a text file that lives in memory:

python
import io
from contextlib import redirect_stdout


def noisy_model_training():
    print("epoch 1/3  loss=0.412")
    print("epoch 2/3  loss=0.233")
    print("epoch 3/3  loss=0.187")
    return 0.187


buffer = io.StringIO()                  # an in-memory text file

with redirect_stdout(buffer):
    final_loss = noisy_model_training()  # its prints go into the buffer

print("nothing was printed by the call above")
print("final loss:", final_loss)
print("captured lines:", buffer.getvalue().strip().splitlines())
nothing was printed by the call above
final loss: 0.187
captured lines: ['epoch 1/3  loss=0.412', 'epoch 2/3  loss=0.233', 'epoch 3/3  loss=0.187']

buffer.getvalue() gives back everything written into the buffer as one string. This is handy in tests, when you want to check what a function printed. When the block ends, print() goes back to the screen automatically.

Analogy: plugging a microphone into a recorder instead of the speakers. The person still talks normally, but nobody in the room hears it; it's all saved on the recording instead.


Part 10: Several Managers in One with

You often need two at once, like reading from one file while writing to another. Nesting one with inside another works:

python
with open("in.csv") as fin:
    with open("out.csv", "w") as fout:
        ...

A single with can also take several managers separated by commas, and it closes them in reverse order. The \ at the end of the first line tells Python the statement carries on onto the next line:

python
with open("context_demo/sales.csv", encoding="utf-8") as fin, \
     open("context_demo/big_regions.csv", "w", encoding="utf-8") as fout:

    fout.write(next(fin))               # copy the header across
    for line in fin:
        region, units = line.strip().split(",")
        if int(units) >= 100:
            fout.write(line)

print(Path("context_demo/big_regions.csv").read_text(encoding="utf-8"))
region,units
north,120
east,140

This copied the header and only the regions with 100 or more units into a new file.

Why reverse order? Think of putting on socks, then shoes. When you take them off, you remove the shoes first, then the socks. The last thing set up is the first thing cleaned up, because later setups may depend on earlier ones.

   with A as a, B as b:          enter A  →  enter B  →  run body  →  exit B  →  exit A
                                  (first in)                         (last in, first out)

From Python 3.10, you can wrap the list in parentheses instead, which is easier to read once there are three or more:

python
with (
    open("sales.csv") as fin,
    open("clean.csv", "w") as fout,
    Timer("copy"),
):
    ...

The last one has no as. You only write as when you need the value.


Part 11: Real-World Examples

Netflix: A Transaction That Rolls Back

This is what __exit__'s three arguments are really for. When a streaming service charges your card, either everything should happen, or nothing should. Either the block finishes and we keep the changes (that's called a commit), or it raises an error and we put everything back the way it was (a rollback).

Analogy: a save point in a video game. Before a difficult level, the game saves your progress. If you fail the level, it reloads the save as if the failed attempt never happened.

python
class InsufficientFunds(Exception):
    pass


class Transaction:
    # Snapshot on the way in; restore the snapshot if the block fails.

    def __init__(self, account):
        self.account = account

    def __enter__(self):
        self.snapshot = dict(self.account)
        return self.account

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            print("  committed:", self.account)
        else:
            self.account.clear()
            self.account.update(self.snapshot)
            print(f"  rolled back after {exc_type.__name__}:", self.account)
        # no return: the exception is not ours to swallow


def charge(account, amount):
    with Transaction(account) as acct:
        acct["balance"] -= amount
        acct["charges"] += 1
        if acct["balance"] < 0:
            raise InsufficientFunds(f"balance would be {acct['balance']}")


wallet = {"balance": 100, "charges": 0}

print("charging 30:")
charge(wallet, 30)

print("\ncharging 500:")
try:
    charge(wallet, 500)
except InsufficientFunds as e:
    print("  refused:", e)

print("\nfinal state:", wallet)
charging 30:
  committed: {'balance': 70, 'charges': 1}

charging 500:
  rolled back after InsufficientFunds: {'balance': 70, 'charges': 1}
  refused: balance would be -430

final state: {'balance': 70, 'charges': 1}

How Transaction works:

  • __enter__ takes a snapshot (a copy) of the account before anything changes, and hands the real account to the block.
  • If the block finishes cleanly, __exit__ prints "committed" and keeps the changes.
  • If the block raises an error, __exit__ restores the account from the snapshot, and (because it returns nothing) lets the error continue to the caller.

The failed charge left no trace: neither the balance nor the charge counter changed.

Why clear() and update()? __exit__ restores the dictionary with clear() and update() instead of writing self.account = self.snapshot. That's because the caller's wallet itself has to change. The assignment would only point self.account at a different dictionary and leave wallet as it was.

Here's the same charge without a Transaction. The balance and the counter both change before the check fails, and nothing puts them back:

python
def charge_unprotected(account, amount):
    account["balance"] -= amount
    account["charges"] += 1
    if account["balance"] < 0:
        raise InsufficientFunds(f"balance would be {account['balance']}")


wallet = {"balance": 70, "charges": 1}

try:
    charge_unprotected(wallet, 500)
except InsufficientFunds as e:
    print("refused:", e)

print("wallet now:", wallet)
refused: balance would be -430
wallet now: {'balance': -430, 'charges': 2}

The charge was refused, but the wallet now sits at -430 with an extra charge counted. That's exactly the mess the context manager prevents.

Any Script: A Temporary Working Directory

os.chdir() changes the working directory (the folder that relative paths start from) for the whole program. Forget to change it back and every relative path used afterwards breaks, in ways that are painful to track down.

Analogy: borrowing a colleague's desk for an hour. You'd better move back to your own desk afterwards, or you'll spend the afternoon looking for your stapler in the wrong drawer.

python
@contextmanager
def working_directory(path):
    previous = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(previous)              # even if the block explodes


start = os.getcwd()

with working_directory("context_demo"):
    print("inside:", os.path.basename(os.getcwd()))
    print("sales.csv visible without a path?", os.path.exists("sales.csv"))

print("back where we started?", os.getcwd() == start)
inside: context_demo
sales.csv visible without a path? True
back where we started? True

Inside the block, sales.csv could be found without writing context_demo/ in front of it. After the block, we were back where we started. And the finally makes sure the directory is put back even when the block fails:

python
try:
    with working_directory("context_demo"):
        raise RuntimeError("something went wrong in here")
except RuntimeError as e:
    print("caught:", e)

print("still back where we started?", os.getcwd() == start)
caught: something went wrong in here
still back where we started? True

Any Data Pipeline: Writing a File Safely

Writing a file directly has a hidden risk. Opening a file with "w" empties it straight away. So if the program crashes halfway through writing, the old contents are already gone and the new ones are only half written. Here's that happening:

python
plain = "context_demo/plain_report.txt"

with open(plain, "w", encoding="utf-8") as f:
    f.write("Regional report\nnorth: 120\n")       # a good file to start with

try:
    with open(plain, "w", encoding="utf-8") as f:   # "w" empties the file right here
        f.write("Regional report\n")
        f.write("north: " + str(int("nrth")))       # crashes mid-write
except ValueError as e:
    print("write failed:", e)

print("what's left:", repr(Path(plain).read_text(encoding="utf-8")))
write failed: invalid literal for int() with base 10: 'nrth'
what's left: 'Regional report\n'

The north: 120 line from the good version is gone, and only half of the new report made it in.

The fix is to write to a temporary file first, and only swap it into place once the writing has finished. This is called an atomic write: it either happens completely or not at all. os.replace() does the swap in one step, replacing the old file.

Analogy: a stage crew changes the scenery behind a closed curtain, then opens the curtain on the finished set. The audience never sees a half-built stage. If something goes wrong backstage, they just keep looking at the old scene.

   atomic_write("report.txt")
        │
        ▼
   write everything into  report.txt.tmp   (the real report.txt is untouched)
        │
        ├── block succeeded → close the temp file, os.replace(tmp → report.txt)   ✔ new report in place
        │
        └── block crashed   → close and DELETE the temp file, re-raise the error  ✔ old report untouched
python
@contextmanager
def atomic_write(path, encoding="utf-8"):
    temp_path = path + ".tmp"
    f = open(temp_path, "w", encoding=encoding)
    try:
        yield f                         # the block writes into the temp file
    except Exception:
        f.close()
        os.remove(temp_path)            # failed, so throw the partial file away
        raise                           # and let the caller know
    else:
        f.close()
        os.replace(temp_path, path)     # succeeded, so swap it into place

Here, atomic_write uses try/except/else instead of try/finally, because success and failure need different cleanup:

  • except Exception: runs only if the block failed. It throws away the half-written temp file, then uses a bare raise to send the same error on to the caller.
  • else: runs only if the block succeeded. It swaps the finished temp file into place.
python
report = "context_demo/report.txt"

with atomic_write(report) as f:
    f.write("Regional report\n")
    f.write("north: 120\n")

print("first write:")
print(Path(report).read_text(encoding="utf-8"))
first write:
Regional report
north: 120

Now let's crash halfway through a second write:

python
try:
    with atomic_write(report) as f:
        f.write("Regional report\n")
        f.write("north: " + str(int("nrth")))   # crashes mid-write
except ValueError as e:
    print("write failed:", e)

print("\nthe good file is untouched:")
print(Path(report).read_text(encoding="utf-8"))
print("no leftover .tmp file:", not os.path.exists(report + ".tmp"))
write failed: invalid literal for int() with base 10: 'nrth'

the good file is untouched:
Regional report
north: 120

no leftover .tmp file: True

Two things to notice. The old report survived a crash that happened after writing had already started. And no leftover .tmp file was left lying around.

(One small detail: os.replace() is only guaranteed to swap in a single step when the temporary file and the real file are on the same drive. Keeping the .tmp file right next to the real one, as atomic_write does, takes care of that.)


Part 12: Going Further, ExitStack

Feel free to skip this part on a first read. You'll know when you need it.

A comma-separated with handles a fixed number of managers. ExitStack handles a number you only find out while the program runs (for example, "open every file in this list", where the list could have 3 files or 300). It closes every one of them, even if opening, say, the fourth one fails.

Analogy: a coat-check counter that accepts any number of coats as people arrive, and guarantees to hand every single one back at closing time, in reverse order.

python
from contextlib import ExitStack

names = ["sales.csv", "report.txt", "big_regions.csv"]

with ExitStack() as stack:
    files = [stack.enter_context(open(f"context_demo/{n}", encoding="utf-8"))
             for n in names]
    print("opened", len(files), "files at once")
    for name, f in zip(names, files):
        print(f"  {name:18} first line: {f.readline().strip()}")

print("all closed:", all(f.closed for f in files))
opened 3 files at once
  sales.csv          first line: region,units
  report.txt         first line: Regional report
  big_regions.csv    first line: region,units
all closed: True

stack.enter_context(cm) enters a manager straight away and remembers to exit it later. When the with block ends, everything it remembered is exited in reverse order.


Part 13: Common Mistakes

Mistake Symptom Fix
__enter__ with no return self 'NoneType' object has no attribute ... Return something
Cleanup after yield, no try/finally Teardown silently skipped when the block raises Wrap the yield
__exit__ returning a truthy non-boolean Exceptions vanish for no visible reason Return True, False, or nothing
Reusing a @contextmanager object Cryptic AttributeError on the second with Call the function again each time
Several statements inside with suppress(...) Later statements silently skipped One statement per block
with on a plain value object does not support the context manager protocol Pass the manager, not the path or number

One more: don't open a file without with. The plain open()/close() style is useful for understanding what a file object is, but from here on there's no reason to write it that way.


Quick Reference Summary

What with expands to

python
manager = EXPR
value = manager.__enter__()       # 'as value'
try:
    body
finally:
    manager.__exit__(exc_type, exc_value, traceback)

The class form

python
class Resource:
    def __enter__(self):
        # acquire
        return self               # what 'as' binds
    def __exit__(self, exc_type, exc_value, traceback):
        # release: always called
        return False              # True would swallow the exception

The generator form

python
from contextlib import contextmanager

@contextmanager
def resource():
    # acquire
    try:
        yield value               # what 'as' binds
    finally:
        pass                      # release

__exit__'s arguments

Block finished exc_type exc_value traceback
Cleanly None None None
By raising the exception class the exception object its traceback

contextlib essentials

Tool Purpose
@contextmanager Generator function to context manager
suppress(SomeError) Ignore one kind of error (one statement per block)
closing(obj) Call obj.close() on the way out
redirect_stdout(buf) Capture what a block prints
ExitStack() A number of managers known only at runtime

Several at once

python
with open("in.csv") as fin, open("out.csv", "w") as fout:
    ...

Key words

Term Meaning
Context manager An object with __enter__ and __exit__, usable in with
Protocol A set of methods an object must have to work with a feature
Commit / rollback Keep the changes / undo them
Atomic Happens completely or not at all
Truthy / falsy Values that if treats as true / false

When to use one: any time you'd otherwise write "and then remember to put it back". Files, connections, locks, settings, directories, transactions, timers.

A context manager is a pair of steps, setup and cleanup, packaged so that the cleanup always happens, even when the code in the middle crashes. with simply calls __enter__ on the way in and __exit__ on the way out, and @contextmanager lets a generator do both with a single yield in the middle. Everything in this guide follows from that: why the file closes after a crash, why a missing return self gives you None, why a stray return 2 can hide an error, and how a few lines can give you rollbacks and safe file writes. Next up, in chapter 15, we tour the standard library modules you'll reach for most: collections, itertools, functools and datetime.