datarekha

Context Managers

The with statement, why open() is the gold standard, and how to write your own context managers for transactions, temp dirs, and guaranteed teardown.

9 min read Intermediate Python Lesson 16 of 41

What you'll learn

  • What the with statement actually does — __enter__ and __exit__
  • Why open() is the canonical example, and what leaks without it
  • contextlib.contextmanager for writing one quickly with yield
  • Real uses — DB transactions, temp dirs, and reverse-order cleanup

Before you start

A context manager is an object that knows how to set up a resource and, crucially, how to tear it down — a file to close, a lock to release, a database transaction to commit or roll back, a temporary directory to delete. The with statement is the syntax that drives it, and its one promise is the valuable one: the teardown runs no matter what, even if the code in the middle raises an exception. That guarantee is why with shows up in almost every serious Python program.

The leak you have probably written

Here is the bug context managers exist to prevent. The cleanup line sits after the work — and never runs if the work raises:

# BAD: the file stays open if anything raises before .close().
def write_bad(path):
    f = open(path, "w")
    f.write("hello\n")
    raise RuntimeError("boom")    # f.close() below is never reached
    f.close()

try:
    write_bad("/tmp/demo.txt")
except RuntimeError as e:
    print("got:", e)
print("file leaked — descriptor stays open until the garbage collector runs")
got: boom
file leaked — descriptor stays open until the garbage collector runs

The f.close() after the raise is dead code. On a busy server doing this in a loop, leaked descriptors pile up until the process hits its file-handle limit and everything grinds to a halt. The fix is one keyword.

The with statement, done right

def write_good(path):
    with open(path, "w") as f:
        f.write("hello\n")
        raise RuntimeError("boom")
    # __exit__ already ran on the way out — the file is closed despite the raise

try:
    write_good("/tmp/demo.txt")
except RuntimeError as e:
    print("got:", e)

# The exception still propagated, but the file was closed AND flushed first:
with open("/tmp/demo.txt") as f:
    print("contents:", repr(f.read()))
got: boom
contents: 'hello\n'

Same logic, one extra keyword. The exception still propagates — with does not swallow it — but the file is closed and its buffer flushed on the way out regardless, which is why reading it back shows the hello made it to disk.

The protocol — __enter__ and __exit__

A context manager is simply any object with two methods. with X as y: calls X.__enter__() and binds its return value to y; when the block ends — normally or by exception — it calls X.__exit__(). To see the protocol clearly, here is a tracer whose only job is to announce when each method fires:

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

    def __enter__(self):
        print(f"  enter {self.label}")
        return self                       # this is what 'as t' receives

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            print(f"  exit  {self.label} (clean)")
        else:
            print(f"  exit  {self.label} (saw {exc_type.__name__})")
        return False                      # falsy -> let any exception propagate

with Trace("block A") as t:
    print("  inside, t.label =", t.label)

print()

try:
    with Trace("block B"):
        raise ValueError("boom")
except ValueError as e:
    print("caught:", e)
  enter block A
  inside, t.label = block A
  exit  block A (clean)

  enter block B
  exit  block B (saw ValueError)
caught: boom

Trace the two blocks. The first exits cleanly, so __exit__ sees exc_type is None. The second raises inside the block — and __exit__ still runs, now told exactly which exception occurred through its three arguments (exc_type, exc_value, traceback). Returning a falsy value (False or None) means “I cleaned up, now let the exception continue”, which is the normal case; returning True would suppress the exception, which you want only rarely.

enter()set up, bind ‘as y’block bodymay return OR raiseexit()ALWAYS runs — teardownWhether the body finishes or raises, exit runs on the way out.

The fast path — @contextmanager

Writing a whole class for every context manager is more ceremony than most cases deserve. contextlib.contextmanager turns a generator into one: everything before the yield is setup, everything after is teardown, and the with-block runs at the yield:

from contextlib import contextmanager

@contextmanager
def trace(label):
    print(f"  enter {label}")
    try:
        yield                             # the body of the with-block runs here
    finally:
        print(f"  exit  {label}")

with trace("download"):
    print("  ... working ...")
  enter download
  ... working ...
  exit  download

The try/finally is doing essential work. Because the teardown lives in the finally, it runs even if the with-block raises — without it, an exception in the body would skip straight past the cleanup, defeating the entire purpose.

A real example — a database transaction

Commit on success, roll back on failure: that discipline is easy to forget by hand and impossible to forget inside a context manager. Here is the whole pattern with a fake database so the output is exact:

from contextlib import contextmanager

class FakeDB:
    def __init__(self):
        self.committed = []
        self.pending = []
    def insert(self, row):
        self.pending.append(row)
        print(f"  staged: {row}")
    def commit(self):
        self.committed.extend(self.pending)
        self.pending.clear()
        print("  COMMIT")
    def rollback(self):
        self.pending.clear()
        print("  ROLLBACK")

@contextmanager
def transaction(db):
    try:
        yield db
        db.commit()
    except Exception:
        db.rollback()
        raise

db = FakeDB()

# Happy path — commits.
with transaction(db) as tx:
    tx.insert({"id": 1, "user": "Aarav"})
    tx.insert({"id": 2, "user": "Priya"})

# Failure path — rolls back, and the exception still propagates.
try:
    with transaction(db) as tx:
        tx.insert({"id": 3, "user": "Diego"})
        raise ValueError("payment failed")
except ValueError as e:
    print("caught:", e)

print("committed rows:", db.committed)
  staged: {'id': 1, 'user': 'Aarav'}
  staged: {'id': 2, 'user': 'Priya'}
  COMMIT
  staged: {'id': 3, 'user': 'Diego'}
  ROLLBACK
caught: payment failed
committed rows: [{'id': 1, 'user': 'Aarav'}, {'id': 2, 'user': 'Priya'}]

Two paths, one shape. The first block committed both rows; the second staged a row, hit a ValueError, rolled the staging back, and re-raised — so Diego never reached committed. The with block makes that commit-or-rollback discipline structural rather than something you have to remember.

Multiple contexts at once

You can manage several resources in one with. Since Python 3.10, parentheses let you split them across lines — much tidier for three or more:

# One line — fine for two, gets unwieldy fast.
with open("in.csv") as src, open("out.csv", "w") as dst:
    dst.write(src.read())

# Python 3.10+ — parentheses allow a clean line break per resource.
with (
    open("in.csv") as src,
    open("out.csv", "w") as dst,
):
    dst.write(src.read())

Contexts unwind in reverse order — the last one entered is the first one exited. That is almost always what you want: close the output before the input, release the inner lock before the outer.

A throwaway — a temporary directory

tempfile.TemporaryDirectory makes a real directory and deletes it (and everything inside) on exit, even if the block raises:

import tempfile, os

with tempfile.TemporaryDirectory() as tmp:
    path = os.path.join(tmp, "scratch.txt")
    with open(path, "w") as f:
        f.write("temporary stuff")
    print("exists during with:", os.path.exists(path))

print("exists after with: ", os.path.exists(path))
exists during with: True
exists after with:  False

In one breath

  • with X as y: calls X.__enter__() (binding y) and is guaranteed to call X.__exit__() on the way out.
  • __exit__ runs on success and on exception; it receives the exception details and returns falsy to let it propagate.
  • @contextmanager turns a generator into a context manager — setup before yield, teardown after, inside a try/finally.
  • Use it for any acquire/release resource — files, locks, transactions, temp dirs.
  • Multiple contexts unwind in reverse order; the 3.10+ parenthesised form keeps them readable.

Practice

Quick check

0/3
Q1What does `with open(p) as f:` guarantee?
Q2In `@contextmanager`, where does the body of the with-block run?
Q3If __exit__ returns True, what happens to an exception raised inside the with-block?

What’s next

Exceptions have surfaced in nearly every lesson. It is time to meet them properly — errors and exceptions: the hierarchy, how to catch them well, and how to raise your own.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is a context manager and how does the with statement work?

A context manager is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — guaranteed, even if an exception is raised. This makes with the idiomatic way to manage resources like files, locks, and database transactions without leaking them.

What is a closure in Python, and what problem does it solve?

A closure is a function that retains access to variables from its enclosing scope even after that scope has finished executing. It is the mechanism behind decorators, factory functions, and stateful callbacks without needing a class.

What is the difference between CPU-bound and I/O-bound work, and how does the choice affect concurrency strategy in Python?

CPU-bound work keeps the processor busy the whole time — matrix multiplication, compression, parsing. I/O-bound work spends most of its time waiting for a slow external resource — network, disk, database. The distinction directly determines which concurrency primitive to reach for: multiprocessing for CPU-bound (bypasses the GIL), threading or asyncio for I/O-bound (GIL released during waits).

When should you use threading versus multiprocessing in Python?

Use threading for I/O-bound work — network calls, file reads, database queries — because threads release the GIL during blocking syscalls and share memory cheaply. Use multiprocessing for CPU-bound work — number crunching, image processing — because each process gets its own GIL and can run on a separate core.

Related lessons

Explore further

Skip to content