Python Lesson 37 – | Dataplexa

Context Managers

Every time you open a file with with open(...) as f:, you are using a context manager. Context managers guarantee that setup and teardown logic runs reliably — even when exceptions occur. They are the cleanest solution to the resource management problem: ensuring that files are closed, database connections are released, locks are freed, and temporary state is restored, no matter what happens inside the block.

This lesson covers how the with statement works, how to build context managers using classes and the contextlib module, and the real-world scenarios where they save you from subtle, hard-to-debug resource leaks.

The Problem Context Managers Solve

Without a context manager, resource cleanup depends on the programmer remembering to do it — and on no exception occurring before the cleanup code runs.

# Without a context manager — fragile cleanup

f = open("data.txt", "w")
f.write("some data")
f.close()   # fine if nothing goes wrong

# But if an exception occurs before close(), the file stays open
f = open("data.txt", "w")
try:
    f.write("some data")
    raise ValueError("something went wrong")   # simulated error
finally:
    f.close()   # must wrap in try/finally to guarantee cleanup
    print("File closed in finally block")
File closed in finally block

The with statement replaces the try/finally pattern with a single clean line — and the teardown is guaranteed automatically.

How the with Statement Works

The with statement relies on two magic methods: __enter__ runs at the start of the block and __exit__ runs at the end — guaranteed, even if an exception occurs. The object that implements these two methods is the context manager.

# The with statement — clean, guaranteed resource management

# Using the built-in file context manager
with open("data.txt", "w") as f:
    f.write("Hello from context manager!")
# f.close() is called automatically here — even if an exception occurred

# Reading it back
with open("data.txt", "r") as f:
    content = f.read()
    print(content)

# Nesting context managers
with open("input.txt", "w") as inp, open("output.txt", "w") as out:
    inp.write("input data")
    out.write("output data")
    print("Both files written and closed automatically.")
Hello from context manager!
Both files written and closed automatically.
  • The as f part binds the value returned by __enter__ to the variable f
  • Multiple context managers can be combined on one line with a comma
  • __exit__ is always called — even if an exception is raised inside the block

Building a Class-Based Context Manager

Any class that implements __enter__ and __exit__ is a context manager. This gives you full control over setup, teardown, and exception handling.

Why it exists: the class-based approach is ideal when the context manager needs to maintain state or when the setup and teardown logic is complex enough to deserve its own class.

Real-world use: a database connection class implements __enter__ to open the connection and __exit__ to commit or roll back the transaction and close the connection — all guaranteed regardless of what happens inside the block.

# Class-based context manager — __enter__ and __exit__

class ManagedFile:
    """A context manager that opens and closes a file."""

    def __init__(self, filename, mode):
        self.filename = filename
        self.mode     = mode

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        print(f"Opened: {self.filename}")
        return self.file            # bound to the 'as' variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        print(f"Closed: {self.filename}")
        if exc_type:
            print(f"Exception suppressed: {exc_val}")
        return True                 # True suppresses any exception

with ManagedFile("notes.txt", "w") as f:
    f.write("context managers are great")
    raise ValueError("oops!")      # exception raised inside block

print("Execution continues — exception was suppressed")
Opened: notes.txt
Closed: notes.txt
Exception suppressed: oops!
Execution continues — exception was suppressed
  • __exit__ receives three arguments: exc_type, exc_val, exc_tb — all None if no exception occurred
  • Return True from __exit__ to suppress the exception; return False (or nothing) to let it propagate
  • __enter__ returns the value that gets bound to the variable after as
  • In most real-world cases you want exceptions to propagate — only suppress when you have a specific reason

Building a Context Manager with contextlib

Writing a full class for a simple context manager is often overkill. The contextlib.contextmanager decorator lets you write a context manager as a generator function — everything before yield is setup, yield is the value bound to as, and everything after is teardown.

Why it exists: it eliminates the class boilerplate for straightforward context managers while keeping the setup/teardown guarantee.

# contextlib.contextmanager — generator-based context manager

from contextlib import contextmanager

@contextmanager
def managed_file(filename, mode):
    print(f"Opening: {filename}")
    f = open(filename, mode)
    try:
        yield f           # value bound to the 'as' variable
    finally:
        f.close()         # always runs — setup in try, teardown in finally
        print(f"Closed: {filename}")

with managed_file("notes.txt", "w") as f:
    f.write("generator-based context manager")
    print("Writing done.")
Opening: notes.txt
Writing done.
Closed: notes.txt
  • The try/finally inside the generator ensures teardown runs even if the block raises an exception
  • Only one yield is allowed — it marks the boundary between setup and teardown
  • This is the preferred pattern for simple context managers — less code, same guarantees

Practical Example — Timer Context Manager

A timer context manager measures how long a block of code takes to execute — cleaner than manually recording start and end times around every block you want to measure.

# Timer context manager — measures block execution time

import time
from contextlib import contextmanager

@contextmanager
def timer(label=""):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label} took {elapsed:.4f}s")

with timer("Sum of range"):
    result = sum(range(5_000_000))
    print("Result:", result)

with timer("List comprehension"):
    squares = [x**2 for x in range(1_000_000)]
Result: 12499997500000
Sum of range took 0.1823s
List comprehension took 0.0741s

Practical Example — Temporary Directory Change

A context manager that temporarily changes the working directory and restores it afterwards — no matter what happens inside the block.

# Temporary working directory context manager

import os
from contextlib import contextmanager

@contextmanager
def change_dir(path):
    original = os.getcwd()     # save current directory
    os.chdir(path)             # switch to new directory
    print(f"Changed to: {os.getcwd()}")
    try:
        yield
    finally:
        os.chdir(original)     # always restore original directory
        print(f"Restored to: {os.getcwd()}")

print("Before:", os.getcwd())

with change_dir("/tmp"):
    print("Inside block:", os.getcwd())

print("After:", os.getcwd())
Before: /home/user/project
Changed to: /tmp
Inside block: /tmp
Restored to: /home/user/project
After: /home/user/project
  • The original directory is restored even if an exception is raised inside the block
  • This pattern works for any "temporarily change something and restore it" scenario — environment variables, configuration settings, mock patches

contextlib Utilities Worth Knowing

The contextlib module ships several ready-made context managers that handle common patterns.

# Useful contextlib utilities

from contextlib import suppress, redirect_stdout, nullcontext
import io

# suppress — silently ignore specific exceptions
with suppress(FileNotFoundError):
    open("nonexistent_file.txt")   # no error raised
print("suppress: carried on after missing file")

# redirect_stdout — capture print output into a string
buffer = io.StringIO()
with redirect_stdout(buffer):
    print("this goes to the buffer, not the terminal")
captured = buffer.getvalue()
print("captured:", captured.strip())

# nullcontext — a no-op context manager (useful in conditional code)
debug_mode = False
ctx = timer("block") if debug_mode else nullcontext()
with ctx:
    x = sum(range(100))
suppress: carried on after missing file
captured: this goes to the buffer, not the terminal
  • suppress(*exceptions) — cleanest way to intentionally swallow a specific exception
  • redirect_stdout(buffer) — useful in testing to capture output without mocking
  • nullcontext() — a context manager that does nothing, useful when a context manager is optional
  • contextlib.closing(obj) — wraps any object with a close() method in a context manager

Summary Table

Tool Approach Best Used For
__enter__ / __exit__ Class-based Complex setup/teardown, stateful managers
@contextmanager Generator-based Simple managers — less boilerplate
suppress() contextlib utility Intentionally swallowing specific exceptions
redirect_stdout() contextlib utility Capturing print output in tests
nullcontext() contextlib utility Optional context manager in conditional code

Practice Questions

Practice 1. What two magic methods must a class implement to work as a context manager?



Practice 2. What does returning True from __exit__ do?



Practice 3. In a @contextmanager generator, what does yield mark?



Practice 4. Which contextlib utility silently ignores a specific exception type?



Practice 5. Is __exit__ called when an exception occurs inside the with block?



Quiz

Quiz 1. What is the main advantage of using a context manager over a plain try/finally block?






Quiz 2. What value is bound to the variable after as in a with statement?






Quiz 3. In a @contextmanager generator, why should the yield be inside a try/finally?






Quiz 4. What are the three arguments __exit__ receives when an exception occurs?






Quiz 5. Which approach is preferred for simple context managers that do not need state?






Next up — Multithreading: running tasks concurrently using Python's threading module.