Context Managers and the with Statement

Cleanup that happens even when things go wrong

Advanced 12 min

In this lesson

You have written with open(...) since Unit 3 without asking what with does. It runs a context manager: an object with a setup step and a cleanup step, where the cleanup runs whatever happens — including when your code raises halfway through.

Explain it like I’m 5

A context manager is a door that closes itself behind you, even if you leave in a hurry because something caught fire.

Two methods, and a guarantee

A context manager is any object with __enter__ and __exit__. with calls __enter__ before the block, and __exit__ after it, on the way out, no matter how you leave.

Whatever __enter__ returns is what as binds. That is where the file object in with open(...) as f comes from.

Example
class Tracked:
    def __enter__(self):
        print("enter")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("exit")
        return False          # do not swallow exceptions

with Tracked():
    print("body")
Output
enter
body
exit
Setup, your code, cleanup.

The demonstration that matters

Everything above is also true of ordinary code written in order. The reason context managers exist is what happens when the block fails: __exit__ still runs.

Cleanup written at the end of a block is skipped by an exception. Cleanup in __exit__ is not. That is the whole value.

Example
class Tracked:
    def __enter__(self):
        print("enter")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("exit  (exception was:", exc_type.__name__ if exc_type else None, ")")
        return False

try:
    with Tracked():
        print("body starts")
        raise ValueError("boom")
        print("never reached")
except ValueError as err:
    print("caught outside:", err)
Output
enter
body starts
exit  (exception was: ValueError )
caught outside: boom
The body blew up. The cleanup still ran.

The shorter way to write one

Two methods and a class is a lot of ceremony for ‘do this, then that’. contextlib.contextmanager turns a generator into a context manager: everything before the yield is setup, everything after is cleanup.

Put the yield in a try/finally and the cleanup survives exceptions, exactly as __exit__ would.

Example
from contextlib import contextmanager

@contextmanager
def tracked(label):
    print(f"enter {label}")
    try:
        yield label          # the with-block runs here
    finally:
        print(f"exit {label}")   # runs even if the block raises

with tracked("job") as name:
    print("working on", name)

try:
    with tracked("risky"):
        raise ValueError("boom")
except ValueError:
    print("handled outside")
Output
enter job
working on job
exit job
enter risky
exit risky
handled outside
One generator, same guarantee, far less code.

Write a context manager that announces entering and leaving a section. Print "start: NAME" on the way in and "end: NAME" on the way out, including when the block raises. Use try/finally around the yield.

from contextlib import contextmanager

@contextmanager
def section(name):
    pass  # TODO: print "start: NAME", yield, then print "end: NAME" in a finally

with section("load"):
    print("loading data")

try:
    with section("save"):
        raise ValueError("disk full")
except ValueError as err:
    print("caught:", err)

Where you will actually use them

Anything acquired then released is a candidate: files, database connections, locks, temporary directories, timers. If you find yourself writing ‘open it, use it, remember to close it’, that last part belongs in a context manager rather than in your memory.

The database connection from Unit 8 is the obvious example. with conn: commits on success and rolls back on failure. That is a context manager doing exactly this job.

Example
from contextlib import contextmanager
import sqlite3

@contextmanager
def database(path=":memory:"):
    conn = sqlite3.connect(path)
    try:
        yield conn
        conn.commit()          # success: save the work
    except Exception:
        conn.rollback()        # failure: undo it
        raise
    finally:
        conn.close()           # always: release the file

with database() as conn:
    conn.execute("CREATE TABLE books (title TEXT)")
    conn.execute("INSERT INTO books VALUES (?)", ("Dune",))
    print("rows:", conn.execute("SELECT COUNT(*) FROM books").fetchone()[0])

print("connection closed cleanly")
Output
rows: 1
connection closed cleanly
Commit, rollback, and close: decided once, not at every call site.

Common mistake: Putting cleanup at the end of the block instead of in __exit__

Why it happens:

It reads in the right order and works whenever nothing fails.

How to fix it:

An exception skips the rest of the block, so that cleanup never runs. Put it in __exit__ or a finally.

Common mistake: Forgetting yield in a @contextmanager function

Why it happens:

It looks like an ordinary function.

How to fix it:

Without yield it is not a generator and with raises an error. There must be exactly one yield, marking the boundary between setup and cleanup.

Common mistake: Returning True from __exit__ by accident

Why it happens:

Returning something feels tidier than returning nothing.

How to fix it:

A truthy return suppresses the exception entirely. Return False or nothing unless you deliberately want it swallowed.

Common mistake: Leaving the yield outside a try/finally

Why it happens:

The cleanup line is right there after the yield, so it looks guaranteed.

How to fix it:

If the block raises, the exception travels back through the yield and skips the rest. Only a finally guarantees it runs.

When does __exit__ run?

In @contextmanager, what separates setup from cleanup?

What happens if __exit__ returns True?

Mini exercise (medium)

Write a collecting context manager that gathers lines. On entry it yields an empty list for the block to append to; on exit it prints "collected N lines", even if the block raises. Prove it by using it twice, once cleanly and once with an exception.

Take the wheel. Complete the code, hit Run, and check your output right here.

from contextlib import contextmanager

@contextmanager
def collecting():
    pass  # TODO: yield an empty list, and print "collected N lines" on the way out

with collecting() as lines:
    lines.append("one")
    lines.append("two")

try:
    with collecting() as lines:
        lines.append("partial")
        raise ValueError("stopped early")
except ValueError as err:
    print("caught:", err)

What to learn next

You found out what with has been doing since Unit 3: __enter__ for setup, __exit__ for cleanup, and the guarantee that the cleanup runs even when the block raises. You wrote the shorter @contextmanager form with try/finally around the yield, and used it to make the forgotten commit() from Unit 8 impossible.

Three tools, one box. The Unit 10 Project assembles them into a small toolkit and points it at the script from Units 7 to 9.