In the previous tutorial, we learned about decorators. Now let’s learn about context managers — the mechanism behind the with statement.

You have been using with since the file I/O tutorial:

with open("file.txt") as f:
    content = f.read()
# File is automatically closed here

But what actually happens behind the scenes? And how do you create your own with-compatible objects? By the end of this tutorial, you will understand the protocol behind context managers and know how to build your own for resource management, timing, transactions, and more.

The Problem: Resource Leaks

Without with, you risk leaving resources open if an error occurs:

# BAD — file stays open if read() raises an error
f = open("file.txt")
content = f.read()  # What if this line raises an error?
f.close()           # This line never runs!

You could use try/finally:

# BETTER — but verbose
f = open("file.txt")
try:
    content = f.read()
finally:
    f.close()

The with statement does the same thing more cleanly:

# BEST — clean, safe, Pythonic
with open("file.txt") as f:
    content = f.read()
# f.close() is called automatically, even on error

The with statement guarantees that cleanup code runs, no matter what happens inside the block. This pattern works for files, database connections, network sockets, locks, and any other resource that needs cleanup.

How Context Managers Work

A context manager is any object with two methods: __enter__ and __exit__.

When you write with something as name:, Python does this:

  1. Calls something.__enter__() — the return value is assigned to name
  2. Runs the with block
  3. Calls something.__exit__() — even if an error occurred

Class-Based Context Managers

Here is a Timer context manager that measures elapsed time:

import time

class Timer:
    """A context manager that measures elapsed time."""

    def __init__(self, label: str = "Block") -> None:
        self.label = label
        self.elapsed = 0.0
        self._start = 0.0

    def __enter__(self):
        """Called when entering the 'with' block."""
        self._start = time.perf_counter()
        return self  # This is what "as t" receives

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Called when exiting the 'with' block."""
        self.elapsed = time.perf_counter() - self._start
        return False  # Do not suppress exceptions

Use it like this:

with Timer("my operation") as t:
    time.sleep(0.1)
    # do some work

print(f"Elapsed: {t.elapsed:.4f}s")  # Elapsed: 0.1001s

Here is what happens step by step:

  1. Timer("my operation") creates the Timer object
  2. __enter__ is called, records the start time, returns self (assigned to t)
  3. The with block runs (sleep for 0.1 seconds)
  4. __exit__ is called, calculates elapsed time
  5. If an error occurred inside the block, __exit__ still runs

The exit Parameters

__exit__ receives three arguments about any exception that occurred:

  • exc_type — the exception class (e.g., ValueError)
  • exc_val — the exception instance
  • exc_tb — the traceback object

If no exception occurred, all three are None.

The return value of __exit__ matters:

  • Return False (or None) — let exceptions propagate normally. This is what you want most of the time.
  • Return Truesuppress the exception. The error is swallowed and the code after the with block continues.

Suppressing Exceptions

You can create a context manager that catches specific exceptions:

class Suppressor:
    """A context manager that suppresses specific exceptions."""

    def __init__(self, *exceptions):
        self.exceptions = exceptions
        self.suppressed = None

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None and issubclass(exc_type, self.exceptions):
            self.suppressed = exc_val
            return True  # Suppress the exception
        return False  # Let other exceptions through
with Suppressor(ValueError, KeyError) as s:
    int("not a number")  # ValueError — suppressed!

print(s.suppressed)  # ValueError: invalid literal for int()
# Code continues normally here

Python’s standard library has contextlib.suppress which does the same thing:

from contextlib import suppress

with suppress(FileNotFoundError):
    Path("missing.txt").unlink()
# No error even if the file does not exist

File Writer Context Manager

Here is a practical example that wraps file writing with automatic closing:

class FileWriter:
    """A context manager for writing to a file."""

    def __init__(self, path):
        self.path = path
        self.file = None
        self.lines_written = 0

    def __enter__(self):
        self.file = open(self.path, "w", encoding="utf-8")
        return self

    def write(self, text: str) -> None:
        """Write a line to the file."""
        self.file.write(text + "\n")
        self.lines_written += 1

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()
        return False
with FileWriter(Path("output.txt")) as writer:
    writer.write("Hello")
    writer.write("World")

print(writer.lines_written)  # 2
print(writer.file.closed)    # True — guaranteed

Even if writer.write() raises an error, __exit__ closes the file.

Generator-Based Context Managers

The @contextmanager decorator from contextlib lets you create context managers using generators. This is often simpler than writing a class:

from contextlib import contextmanager

@contextmanager
def temp_directory():
    """Create a temporary directory and clean it up after use."""
    import tempfile
    tmp_dir = Path(tempfile.mkdtemp())
    try:
        yield tmp_dir  # This is what "as" receives
    finally:
        # Cleanup: remove all files and the directory
        for file in tmp_dir.iterdir():
            file.unlink()
        tmp_dir.rmdir()
with temp_directory() as tmp:
    (tmp / "test.txt").write_text("Hello")
    print(f"Working in: {tmp}")
# Directory is cleaned up here

The pattern for @contextmanager is always:

  1. Setup code — before yield
  2. yield value — the value goes to as
  3. Cleanup code — in finally (or after yield if no errors are possible)

The try/finally ensures cleanup runs even if an error occurs inside the with block.

Logging Block

A simple generator-based context manager for logging:

@contextmanager
def log_block(name: str):
    """Log when entering and exiting a block."""
    print(f"Entering {name}")
    try:
        yield name
    finally:
        print(f"Exiting {name}")
with log_block("database"):
    print("Doing database work")

# Output:
# Entering database
# Doing database work
# Exiting database

Database Connection Pattern

Context managers are perfect for managing connections. The connection opens on enter and closes on exit:

class DatabaseConnection:
    """A simulated database connection context manager."""
    _connections = []

    def __init__(self, url: str) -> None:
        self.url = url
        self.connected = False
        self.queries = []

    def __enter__(self):
        self.connected = True
        DatabaseConnection._connections.append(self)
        return self

    def execute(self, query: str) -> str:
        if not self.connected:
            raise RuntimeError("Not connected to database.")
        self.queries.append(query)
        return f"Result of: {query}"

    @classmethod
    def active_count(cls) -> int:
        """Return number of active connections."""
        return len(cls._connections)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.connected = False
        if self in DatabaseConnection._connections:
            DatabaseConnection._connections.remove(self)
        return False
with DatabaseConnection("localhost:5432") as db:
    result = db.execute("SELECT * FROM users")
    print(result)
    print(f"Active: {DatabaseConnection.active_count()}")

print(db.connected)  # False — automatically disconnected

The connection is always closed, even if a query fails. This prevents connection leaks that can exhaust your database connection pool.

Nested Context Managers

You can nest with statements:

with temp_directory() as tmp:
    with FileWriter(tmp / "output.txt") as writer:
        writer.write("Hello from nested context")

Or use a single with with multiple context managers (Python 3.10+):

with (
    temp_directory() as tmp,
    FileWriter(tmp / "output.txt") as writer,
):
    writer.write("Hello from single with")

The second syntax is cleaner and avoids deep nesting. All context managers are entered in order and exited in reverse order (last entered, first exited).

Transaction Pattern

Context managers can implement transaction-like behavior that rolls back changes on error:

@contextmanager
def transaction(data: dict):
    """Roll back changes to data if an error occurs."""
    backup = dict(data)  # Save a copy
    try:
        yield data
    except Exception:
        # Rollback: restore original data
        data.clear()
        data.update(backup)
        raise  # Re-raise the exception
data = {"name": "Alex", "score": 100}

try:
    with transaction(data):
        data["score"] = 200
        data["new_key"] = "value"
        raise ValueError("Something went wrong")
except ValueError:
    pass

print(data)  # {"name": "Alex", "score": 100} — rolled back!

The backup is made before the block runs. If an error occurs, the data is restored to the backup. The exception is re-raised so the caller knows something went wrong.

When to Use Context Managers

ResourceWhy use with
FilesEnsure files are closed
Database connectionsRelease connections back to pool
Network socketsClose connections
Locks (threading)Release locks to avoid deadlocks
Temporary files/dirsClean up temp files
Timer/profilingEnsure timing stops
TransactionsRollback on error
External processesEnsure processes are terminated

The rule is simple: if you need setup and cleanup that must always happen (even on error), use a context manager. It is the Pythonic way to manage resources.

Class vs Generator Context Manager

ApproachBest for
Class (__enter__/__exit__)Complex state management, multiple methods
@contextmanagerSimple setup/cleanup, minimal state

Most context managers are simpler with @contextmanager. Use a class when you need to store state across multiple method calls or when the context manager is complex enough to benefit from proper class structure.

Common Mistakes

Forgetting to Return False

# BAD — accidentally suppresses all exceptions!
def __exit__(self, exc_type, exc_val, exc_tb):
    self.cleanup()
    return True  # Suppresses ALL exceptions

# GOOD — let exceptions propagate
def __exit__(self, exc_type, exc_val, exc_tb):
    self.cleanup()
    return False

Not Using finally in Generator Context Managers

# BAD — cleanup does not run on error
@contextmanager
def risky():
    resource = acquire()
    yield resource
    release(resource)  # Not reached if error occurs!

# GOOD — cleanup always runs
@contextmanager
def safe():
    resource = acquire()
    try:
        yield resource
    finally:
        release(resource)  # Always runs

Yielding Multiple Times

A @contextmanager generator must yield exactly once:

# BAD — yields twice!
@contextmanager
def broken():
    yield "first"
    yield "second"  # RuntimeError!

If you need to yield multiple values, yield a tuple or an object.

Context Managers in the Standard Library

Python has many built-in context managers:

# File operations
with open("file.txt") as f:
    ...

# Temporary files
with tempfile.TemporaryDirectory() as tmp:
    ...

# Suppress exceptions
with contextlib.suppress(FileNotFoundError):
    ...

# Change working directory (Python 3.11+)
with contextlib.chdir("/tmp"):
    ...

# Threading locks
import threading
lock = threading.Lock()
with lock:
    # Only one thread can be here at a time
    shared_data += 1

# Database transactions (SQLAlchemy example)
with Session() as session:
    session.add(user)
    session.commit()

The with statement is one of Python’s most powerful patterns. Once you understand it, you will see opportunities to use it everywhere.

Async Context Managers

If you work with async code (covered in a later tutorial), you can create async context managers:

class AsyncDB:
    async def __aenter__(self):
        self.conn = await connect()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.conn.close()
        return False

# Used with "async with"
async with AsyncDB() as db:
    await db.execute("SELECT 1")

Or with @asynccontextmanager:

from contextlib import asynccontextmanager

@asynccontextmanager
async def async_temp_file():
    path = create_temp()
    try:
        yield path
    finally:
        await async_delete(path)

We will cover async in more detail in a future tutorial.

Testing Context Managers

Context managers should be tested like any other code. Test both the happy path and the error path:

def test_timer_measures_time():
    with Timer("test") as t:
        time.sleep(0.01)
    assert t.elapsed > 0

def test_file_writer_closes_on_error():
    with temp_directory() as tmp:
        path = tmp / "test.txt"
        try:
            with FileWriter(path) as writer:
                writer.write("data")
                raise ValueError("error")
        except ValueError:
            pass
        assert writer.file.closed  # File closed despite error

def test_transaction_rollback():
    data = {"score": 100}
    try:
        with transaction(data):
            data["score"] = 200
            raise ValueError("error")
    except ValueError:
        pass
    assert data["score"] == 100  # Rolled back

Test that cleanup happens even when exceptions occur. This is the whole point of context managers.

Source Code

You can find the code for this tutorial on GitHub:

kemalcodes/python-tutorial — tutorial-15-context-managers

Run the examples:

python src/py15_context_managers.py

Run the tests:

python -m pytest tests/test_py15.py -v

What’s Next?

In the next tutorial, we will learn about type hints — how to annotate your code to catch bugs before they happen.