Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

Context Managers and the with Statement

What with is actually doing, and building your own context managers for resource management.

30 min August 2026
// Part 01 — What with Is Actually For

Guaranteed Cleanup, Even When Things Go Wrong

You met with open(...) as f: back in the Reading & Writing Files module, with a promise that it would be explained properly later — this is that module. The with statement exists to guarantee that some cleanup action (closing a file, releasing a lock, closing a database connection) happens no matter how the block inside exits — normally, via an early return, or because an exception was raised.

The manual, error-prone equivalent
f = open("data.txt")
data = f.read()
process(data)     # if this raises an exception, f.close() below NEVER RUNS — file stays open
f.close()
with — cleanup is guaranteed regardless of how the block exits
with open("data.txt") as f:
    data = f.read()
    process(data)   # even if this raises, the file is still closed correctly
# f.close() has already happened automatically here

This is not a minor convenience — a leaked file handle, database connection, or lock in production code is a genuinely common category of real bug, and it is exactly the class of bug with makes structurally difficult to write in the first place.

// Part 02 — The Protocol Underneath

__enter__ and __exit__

A with statement works with any object that implements two dunder methods: __enter__, called at the start of the block (its return value is what as binds to), and __exit__, called when the block ends — no matter how it ends.

Building a context manager from scratch
class Timer:
    def __enter__(self):
        import time
        self.start = time.time()
        return self            # this becomes the value bound by "as"

    def __exit__(self, exc_type, exc_value, traceback):
        import time
        elapsed = time.time() - self.start
        print(f"Elapsed: {elapsed:.3f}s")
        return False            # False = don't suppress any exception (see Part 03)

with Timer() as t:
    total = sum(range(10_000_000))
# Elapsed: 0.412s   <- printed automatically when the block ends

__exit__ always receives three arguments describing any exception that occurred inside the block — exc_type, exc_value, and traceback — all three are None if the block completed normally with no exception at all.

// Part 03 — The Exception-Suppression Trap

What __exit__'s Return Value Actually Controls

This is the single most important and most misunderstood detail of the protocol: if __exit__ returns a truthy value, Python treats the exception as handled and suppresses it entirely — the exception simply vanishes, as if it never happened, and code after the with block continues executing normally.

A dangerous, accidental footgun
class BadLogger:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is not None:
            print(f"Something went wrong: {exc_value}")
        return True    # DANGER: this SUPPRESSES the exception entirely!

with BadLogger():
    result = 10 / 0    # ZeroDivisionError happens...
print("Program continues normally")
# Something went wrong: division by zero
# Program continues normally     <- the exception was silently swallowed, "result" was NEVER assigned!
⚠️ Important
Almost every context manager should return False (or nothing, which is the same as None — also falsy) from __exit__. Deliberately suppressing exceptions is a legitimate, narrow use case (for example, a context manager whose entire purpose is ignoring a specific known error type), but doing it accidentally — by returning a truthy value out of habit, or by the last statement inside __exit__ happening to evaluate truthy — silently hides real bugs and is extremely difficult to debug later, since the program simply continues as if nothing failed.
// Part 04 — contextlib.contextmanager

Writing a Context Manager With a Generator Instead of a Class

Writing a full class with __enter__/__exit__ is verbose for simple cases. contextlib.contextmanager lets you write a context manager as a single generator function (generators were covered in the previous phase) — everything before yield becomes __enter__, the yielded value becomes what as binds to, and everything after yield becomes __exit__.

The Timer example, rewritten as a generator-based context manager
from contextlib import contextmanager
import time

@contextmanager
def timer():
    start = time.time()
    try:
        yield              # the "with" block's code runs here, at the yield point
    finally:
        elapsed = time.time() - start
        print(f"Elapsed: {elapsed:.3f}s")

with timer():
    total = sum(range(10_000_000))
# Elapsed: 0.412s
🎯 Pro Tip
Always wrap the yield in a try/finally. If the code inside the with block raises an exception, that exception is raised at the yield statement itself inside your generator — without a finally, your cleanup code after yield would simply never run if an exception occurs, exactly defeating the purpose of using a context manager in the first place.

A genuinely common real use — temporarily changing state and restoring it

Temporarily changing the working directory, then restoring it
from contextlib import contextmanager
import os

@contextmanager
def working_directory(path):
    original = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(original)     # ALWAYS restored, even if the block raises

with working_directory("/tmp"):
    print(os.getcwd())    # /tmp
print(os.getcwd())        # back to the original directory automatically
// Part 05 — Multiple and Nested Context Managers

Combining Several Resources in One with Statement

Multiple context managers, one statement
with open("input.txt") as infile, open("output.txt", "w") as outfile:
    outfile.write(infile.read().upper())
# BOTH files are guaranteed closed here, even if writing raised an exception

This comma-separated form is equivalent to nesting two separate with statements — Python enters each context manager in order, and exits them in reverse order once the block ends, exactly like closing nested parentheses.

A real example — a database connection and a lock together
with db.connection() as conn, conn.lock():
    conn.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
# The lock is released first, THEN the connection is closed — reverse of entry order
// Part 06 — Real World
💼 What This Looks Like at Work

A Connection Pool Exhaustion Incident at a Portland E-commerce Company

Scenario — E-commerce company, Portland · Production incident

A checkout service starts returning "connection pool exhausted" errors under normal traffic. Investigation traces it to a function that manually acquires a database connection and releases it at the end — but a validation check earlier in the function occasionally raises an exception on malformed cart data, skipping the release call entirely.

The bug — manual release, skipped on the exception path
def checkout(cart):
    conn = pool.acquire()
    validate_cart(cart)          # raises ValueError on malformed data — happens under real traffic
    conn.execute("INSERT INTO orders ...")
    pool.release(conn)           # NEVER REACHED if validate_cart raised — connection leaks
The fix — a context manager guarantees release either way
def checkout(cart):
    with pool.connection() as conn:     # pool.connection() is a context manager wrapping acquire/release
        validate_cart(cart)
        conn.execute("INSERT INTO orders ...")
    # conn is released here NO MATTER WHAT — including if validate_cart raised

Why manual acquire/release is considered a code smell in review

Every manually paired "acquire resource / release resource" pattern has exactly this failure mode: any code between the two calls that can raise an exception silently skips the release. The connection pool itself already exposed a context-manager-based pool.connection() for precisely this reason — reviewers at this company now flag any manual acquire()/release() pairing on sight, since a with-based alternative almost always already exists or can be written in a few lines using contextlib.contextmanager.

// Part 07 — Misconceptions

Four Misconceptions About Context Managers

✕ ""with is special syntax just for files""
with works with ANY object implementing __enter__/__exit__ (or written via @contextmanager) — database connections, locks, temporary state changes, network sockets, and plenty of custom resource-management code all use it, not just file handles.
✕ ""__exit__ returning True or False doesn't really matter, as long as cleanup ran""
It matters enormously — returning a truthy value SUPPRESSES any exception that occurred inside the with block entirely, silently swallowing it. Almost every context manager should return False (or nothing) unless deliberately suppressing a specific, known exception type is the actual intent.
✕ ""A try/finally block and a with statement are basically interchangeable""
A with statement using a well-written context manager encapsulates the acquire/release pairing in ONE reusable place, so every call site gets correct cleanup automatically. A hand-written try/finally has to be correctly reproduced at every call site individually — easy to forget once, as shown in the Real World example above.
✕ ""@contextmanager generators don't need a try/finally around the yield, since with handles cleanup""
Without a try/finally wrapping the yield, an exception raised inside the with block propagates out of your generator at the yield point and skips any cleanup code written after it — defeating the entire purpose. The try/finally is not optional.
// Part 08 — Interview Prep

5 Interview Questions — With Complete Answers

What two methods does an object need to work with the with statement, and what does each do?
__enter__, called at the start of the block, whose return value is bound by "as". __exit__, called when the block ends for any reason — normal completion, early return, or an exception — receiving the exception type/value/traceback (all None if nothing went wrong).
What does the return value of __exit__ control, and what is the safe default?
A truthy return value from __exit__ SUPPRESSES any exception that occurred inside the with block, making it vanish silently. The safe default for almost every context manager is to return False (or nothing), letting exceptions propagate normally.
How does contextlib.contextmanager let you write a context manager without a class?
As a generator function decorated with @contextmanager: code before yield becomes __enter__, the yielded value becomes what "as" binds to, and code after yield (which MUST be wrapped in try/finally) becomes __exit__.
Why is a context manager generally preferred over a manual try/finally at each call site?
It centralizes the acquire/release pairing in one reusable place, so every caller automatically gets correct cleanup with a single 'with' line, rather than requiring every call site to correctly hand-write its own try/finally — which is easy to get wrong or forget once, exactly the kind of bug that causes resource leaks in production.
What happens when multiple context managers are combined in one with statement, like "with a, b:"?
They are entered in the order written (a then b), and exited in REVERSE order (b then a) once the block ends — equivalent to nesting them, like closing parentheses in reverse of how they were opened.
// Common Mistakes

Context Manager Mistakes Beginners Make Constantly

Accidentally returning a truthy value from __exit__
This silently suppresses ANY exception raised inside the with block — often by accident, when the last line of __exit__ happens to evaluate truthy. Explicitly "return False" (or nothing at all) unless suppression is truly intended.
Forgetting try/finally around yield in a @contextmanager generator
An exception inside the with block propagates at the yield point in the generator — without finally, any cleanup code written after yield is skipped entirely.
Manually pairing acquire()/release() instead of using an available context manager
Any exception-raising code between the acquire and release calls skips the release, leaking the resource — exactly the production bug shown in the Real World example above.
Forgetting that __enter__ must return the value meant to be bound by "as"
A class-based context manager whose __enter__ has no explicit return statement makes "with Resource() as r:" bind r to None, since a function with no return implicitly returns None.
// Error Library

Errors You Will Hit With Context Managers — And Exactly Why

AttributeError: __enter__
Cause: An object used in a with statement does not implement the context manager protocol at all (no __enter__/__exit__).
Fix: Confirm the object is actually meant to be used as a context manager — many types have a dedicated factory (like pool.connection()) that returns one, rather than the raw object itself.
TypeError: __exit__() takes 1 positional argument but 4 were given
Cause: A hand-written __exit__ method was defined without the required (self, exc_type, exc_value, traceback) signature — Python always calls it with exactly these three exception-info arguments in addition to self.
RuntimeError: generator didn't stop after throw()
Cause: A @contextmanager generator function yields more than once, or catches the exception thrown at the yield point and then yields again instead of letting the function end.
Fix: A @contextmanager generator must yield exactly once — remove any additional yield statements.
(No error at all — the real symptom is a silently swallowed exception)
Cause: __exit__ returns a truthy value, suppressing an exception that should have propagated and been visible.
Fix: Check every __exit__ implementation in the codebase and confirm it returns False (or nothing) unless suppression is a deliberate, documented design choice.

🎯 Key Takeaways

  • with guarantees cleanup code runs no matter how a block exits — normal completion, early return, or an exception — unlike manually paired acquire/release calls, which skip cleanup if an exception occurs in between.
  • The protocol is __enter__ (runs at block start, return value bound by "as") and __exit__ (runs at block end, receiving exception info).
  • A truthy return from __exit__ SUPPRESSES the exception entirely — almost always return False or nothing, unless deliberate suppression is the actual intent.
  • @contextmanager lets you write a context manager as a generator: code before yield is __enter__, the yielded value is bound by "as", code after yield (wrapped in try/finally) is __exit__.
  • Multiple context managers can be combined in one with statement, separated by commas — they enter in order and exit in reverse order.
  • Prefer an existing or custom context manager over hand-written try/finally resource management wherever one is available — it centralizes correct cleanup in one place instead of relying on every call site getting it right.

What comes next

Module 31 closes out the Intermediate & Functional Python phase with closures and the LEGB scope rule — how Python actually resolves variable names, and the scoping bugs that confuse everyone once.

Module 31 → Closures and Scope — The LEGB Rule
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...