Python Context Managers

A context manager is an object that knows how to set something up and reliably tear it down, even if an error happens in between. The most common way you use one is with the with statement — for example, opening a file and having Python close it automatically once you’re done. Context managers are the reason you rarely see close() calls scattered through well-written Python code, and understanding how they work lets you write safer code for files, locks, database connections, and any resource that needs cleanup.

Overview / How it works

Whenever you write with open("data.txt") as f:, Python isn’t doing anything magical — it’s calling two special methods on the object returned by open(). Any object that defines __enter__ and __exit__ follows the context manager protocol, and can be used after with.

Here’s what actually happens, in order:

  1. Python evaluates the expression after with (e.g. open("data.txt")) to get a context manager object.
  2. Python calls that object’s __enter__() method. Whatever __enter__() returns is bound to the variable after as (if there is one).
  3. The indented block runs.
  4. When the block finishes — whether normally, via return, or because an exception was raised — Python calls the object’s __exit__(exc_type, exc_value, traceback) method.
  5. If no exception occurred, __exit__ is called with three None arguments. If an exception occurred, __exit__ receives the exception’s type, value, and traceback object.
  6. If __exit__ returns a truthy value, Python suppresses the exception (pretends it never happened). If it returns None or any falsy value, the exception propagates normally after cleanup runs.

This is exactly equivalent to writing a try/finally block by hand, except the cleanup logic lives inside the object itself instead of being repeated at every call site. That’s the whole point: it moves “remember to clean up” out of your control flow and into the type system, so you can’t forget it.

Under the hood, a file object’s __enter__ simply returns self (which is why as f gives you the file object), and its __exit__ calls self.close() and returns False so exceptions still propagate. This is also why nesting is easy — Python doesn’t have any special-case logic for with beyond calling these two methods, so any object can plug into it.

Syntax

with expression as variable:
    # block that uses variable
    ...
# cleanup already happened here, even if an exception was raised

with expr1 as var1, expr2 as var2:
    # multiple context managers, entered left to right,
    # exited right to left when the block ends
    ...
  • expression — any expression that evaluates to an object implementing __enter__ and __exit__.
  • as variable — optional; binds the value returned by __enter__(). Many context managers (like locks) don’t return anything useful and omit it.
  • Multiple managers separated by commas are equivalent to nesting with statements; if the second one’s __enter__ raises, the first one’s __exit__ still runs.

Examples

Example 1: The classic file-handling case

with open("notes.txt", "w") as f:
    f.write("first line\n")
    f.write("second line\n")

print(f.closed)
Output:
True

Even though the file object f still exists after the block (variables from a with block aren’t deleted), its __exit__ method already called close() on it, so f.closed is True. If you tried to write to f after the block, you’d get a ValueError because the underlying file handle is gone.

Example 2: Writing your own context manager with a class

import time


class Timer:
    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"Elapsed: {self.elapsed:.4f} seconds")
        return False


with Timer() as t:
    total = sum(range(1_000_000))

print(f"Total was {total}")
Output:
Elapsed: 0.0XXX seconds
Total was 499999500000

The exact elapsed time varies each run, but the structure is fixed: __enter__ records the start time and returns self so t refers to the Timer instance, and __exit__ always runs afterward — printing the elapsed time whether or not the block raised an error. Returning False from __exit__ means any exception inside the block would still propagate after this cleanup.

Example 3: A context manager that suppresses exceptions

class IgnoreZeroDivision:
    def __enter__(self):
        return None

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is ZeroDivisionError:
            print("Caught a division by zero, ignoring it.")
            return True
        return False


with IgnoreZeroDivision():
    print("Before the risky operation")
    result = 10 / 0
    print("This line never runs")

print("Program continues normally")
Output:
Before the risky operation
Caught a division by zero, ignoring it.
Program continues normally

Because __exit__ returns True when it sees a ZeroDivisionError, Python swallows that exception instead of letting it propagate. Notice that the line after the division (print("This line never runs")) is skipped — the exception still aborts the rest of the with block itself; only propagation past the block is suppressed. This is the same mechanism contextlib.suppress uses internally.

Example 4: The easier way — contextlib.contextmanager

from contextlib import contextmanager


@contextmanager
def managed_resource(name: str):
    print(f"Acquiring {name}")
    try:
        yield name.upper()
    finally:
        print(f"Releasing {name}")


with managed_resource("database") as handle:
    print(f"Using {handle}")
Output:
Acquiring database
Using DATABASE
Releasing database

The @contextmanager decorator turns a generator function into a context manager without writing a class at all. Code before the yield becomes __enter__, the yielded value becomes what as handle binds to, and code after the yield (typically in a finally) becomes __exit__. If an exception happens inside the with block, it’s raised at the point of the yield inside the generator, so wrapping the yield in try/finally guarantees the release code still runs.

How it works step by step / Under the hood

  • Python looks up __enter__ and __exit__ on the object’s type, not the instance — this is a general rule for special methods, so defining them only via instance.__enter__ = ... won’t work.
  • The value bound by as is whatever __enter__() returns — it does not have to be the context manager itself. In Example 4, as handle gets the yielded string, not the generator object.
  • __exit__ always runs before the exception (if any) continues propagating up the call stack, so any code after the with block only executes if the exception was suppressed or there was none.
  • With multiple comma-separated managers, exit order is the reverse of enter order — just like nested with blocks — which matters when managers depend on each other (e.g. a lock that must be released after the connection using it is closed).

Common Mistakes

Mistake 1: Forgetting that __exit__ must return True to suppress an exception

class LoggingContext:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is not None:
            print(f"An error occurred: {exc_value}")
        # forgot to return True or False explicitly

Since the function has no explicit return, it returns None, which is falsy — so the exception still propagates after the message is printed. That’s often what you want, but if the intent was to suppress it, this is a bug. Always return an explicit True or False from __exit__ so the behavior is unambiguous, rather than relying on the implicit None.

Mistake 2: Using contextmanager without try/finally around the yield

from contextlib import contextmanager

@contextmanager
def bad_resource():
    print("opening")
    yield
    print("closing")  # only runs if no exception was raised!

If the code inside the with block raises, the generator’s yield re-raises that exception at the yield point, and execution never reaches the print("closing") line — the resource leaks. The fix is to always wrap the cleanup in try/finally:

from contextlib import contextmanager

@contextmanager
def good_resource():
    print("opening")
    try:
        yield
    finally:
        print("closing")  # always runs

Mistake 3: Assuming a with block creates a new scope

with open("config.txt") as f:
    data = f.read()

print(data)     # fine, data still accessible
print(f.closed) # also accessible, but f is now unusable for reading

Unlike some languages, Python’s with doesn’t create a new variable scope — f and data remain defined after the block. The mistake is assuming f is unusable or gone; it’s still there, just closed, so calling f.read() afterward raises a ValueError.

Best Practices

  • Prefer with over manual try/finally whenever a library provides a context manager (files, locks, database cursors, tempfile objects) — it’s shorter and harder to get wrong.
  • For simple setup/teardown logic, reach for @contextlib.contextmanager first; write a full class only when you need to hold state across multiple methods or support re-entry.
  • Always wrap the yield in a generator-based context manager with try/finally so cleanup runs even when the block raises.
  • Have __exit__ return False explicitly (or nothing, understanding that’s falsy) unless you specifically intend to swallow exceptions — silently eating errors makes bugs hard to trace.
  • Use contextlib.suppress(SomeError) instead of writing a custom class when all you want is to ignore a specific exception type.
  • Combine multiple related resources in one with statement (with open(a) as fa, open(b) as fb:) instead of nesting indentation levels, for readability.
  • Never rely on __del__ as a substitute for a context manager — its timing is not guaranteed, especially with reference cycles or alternate Python implementations.

Practice Exercises

  • Write a class-based context manager Suppress404 that, when used in a with block, catches a custom NotFoundError exception and prints "Resource not found, skipping." instead of letting it propagate. Any other exception type should propagate normally.
  • Using @contextlib.contextmanager, write a temporary_value(obj, attr, new_value) context manager that temporarily sets obj.attr to new_value for the duration of the with block, and restores the original value afterward — even if the block raises an exception.
  • Predict the output of a program that opens two files with a single comma-separated with open(a) as fa, open(b) as fb: statement, where opening the second file raises FileNotFoundError. Which file’s __exit__ runs, and which doesn’t? Then verify your reasoning by writing and reading through the code.

Summary

  • A context manager is any object implementing __enter__ and __exit__; the with statement calls these automatically.
  • __enter__()‘s return value is bound by as variable; __exit__ receives exception info (or Nones) and runs no matter how the block exits.
  • Returning a truthy value from __exit__ suppresses the exception; returning falsy (including nothing) lets it propagate after cleanup.
  • contextlib.contextmanager lets you write a context manager as a generator function with a single yield, avoiding a full class — just remember try/finally around the yield.
  • Multiple context managers can share one with statement, entering left-to-right and exiting right-to-left.
  • Context managers guarantee cleanup runs, which is why they’re the standard tool for files, locks, network connections, and any other resource that must be released deterministically.