Context Managers & Protocols

Mastering lifecycle guarantees and behavioral contracts.

Why Context Managers Matter

Context managers are Python's answer to RAII (Resource Acquisition Is Initialization). They ensure that setup and teardown logic are inseparable, creating "safe zones" for operations that involve files, sockets, locks, or database transactions. They encode lifecycle guarantees, ensuring resources are acquired and released correctly, even in the presence of errors. At scale, they become a core building block for correctness, safety, and composability.

The Problem: Manual Resource Management

Without context managers, developers must remember to manually clean up resources, leading to common bugs like resource leaks, deadlocks, and inconsistent state.

# Create the file so the three variants below have something to open
with open('data.txt', 'w') as f:
    f.write("hello")

# Bad: Resource leak if exception occurs
leaked = open('data.txt')
try:
    leaked.read()
    raise ValueError("something went wrong")
    leaked.close()  # This never executes!
except ValueError:
    pass
print(f"bad:    still open? {not leaked.closed}")

# Better but verbose
guarded = open('data.txt')
try:
    guarded.read()
    raise ValueError("something went wrong")
except ValueError:
    pass
finally:
    guarded.close()  # Guaranteed to run
print(f"better: still open? {not guarded.closed}")

# Best: Context manager handles everything
try:
    with open('data.txt') as managed:
        managed.read()
        raise ValueError("something went wrong")
except ValueError:
    pass
print(f"best:   still open? {not managed.closed}")
Expected Output:
bad:    still open? True
better: still open? False
best:   still open? False

The Context Manager Protocol

A context manager is any object implementing two magic methods that define entry and exit behavior:

__enter__(self) -> Any
    # Called when entering 'with' block
    # Return value is bound to 'as' variable
    
__exit__(self, exc_type, exc_value, traceback) -> bool | None
    # Called when exiting 'with' block
    # Receives exception info if one occurred
    # Return True to suppress exception, False/None to propagate

Lifecycle of a with block

with__enter__()acquirerun bodyas targetexception?__exit__(None...)normal__exit__(exc...)cleanupcontinuenoyesre-raise unless __exit__ returns True

Figure 1: __enter__ runs once on entry; __exit__ ALWAYS runs on the way out (like finally), receiving the exception info. Returning True from __exit__ suppresses the exception.

Execution Flow: Python calls __enter__() at the start of the with block, then __exit__() at the end, even if exceptions occur.

Implementing a Context Manager Manually

# Create the file first, so the example below has something to open
with open('data.txt', 'w') as f:
    f.write("hello from data.txt")


class ManagedFile:
    def __init__(self, path, mode='r'):
        self.path = path
        self.mode = mode
        self.file = None

    def __enter__(self):
        print(f"Opening {self.path}")
        self.file = open(self.path, self.mode)
        return self.file  # This is what 'as f' receives

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"Closing {self.path}")
        if self.file:
            self.file.close()
        
        # Log exception if one occurred
        if exc_type is not None:
            print(f"Exception occurred: {exc_type.__name__}: {exc_value}")
        
        return False  # Propagate exceptions (don't suppress)

# Usage
with ManagedFile('data.txt') as f:
    content = f.read()
    print(content)
    # File automatically closes, even if exception occurs
Expected Output:
Opening data.txt
hello from data.txt
Closing data.txt
Critical Rule: __exit__() must be exception-safe and idempotent. It should handle cleanup even if partially initialized or called multiple times.

Exception Suppression & Error Handling

The __exit__ method receives exception details and can choose to suppress (swallow) or propagate them.

import os

from contextlib import suppress

# Standard library: suppress specific exceptions
with suppress(FileNotFoundError, PermissionError):
    os.remove('might_not_exist.txt')
print("still running after the missing file")

# A context manager cannot retry its own block. Returning True from
# __exit__ suppresses the exception and resumes AFTER the with statement,
# so the body still runs exactly once. Retrying needs a loop around it.
class SuppressFailure:
    """Swallow whatever the block raises, and record it"""

    def __init__(self):
        self.failed = False
        self.error = None

    def __enter__(self):
        self.failed = False
        self.error = None
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            return False  # Nothing to suppress
        self.failed = True
        self.error = exc_value
        return True  # Suppressed: control resumes after the with block

# Fails the first two times, then succeeds, so the retry loop is visible
tries = []

def risky_operation():
    tries.append(1)
    if len(tries) < 3:
        raise ConnectionError(f"connection refused (try {len(tries)})")
    print("risky_operation succeeded")

# The loop is what retries; the context manager just keeps it readable
attempt_state = SuppressFailure()
for attempt in range(1, 4):
    with attempt_state:
        risky_operation()
    if not attempt_state.failed:
        break
    print(f"Attempt {attempt} failed: {attempt_state.error}")
else:
    print("All 3 attempts failed")

# Selective suppression by exception type
class IgnoreSpecificErrors:
    def __init__(self, *exception_types):
        self.exception_types = exception_types

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        # Suppress only specified exceptions
        return exc_type is not None and issubclass(exc_type, self.exception_types)

with IgnoreSpecificErrors(KeyError):
    {}['missing']
print("KeyError was ignored")

try:
    with IgnoreSpecificErrors(KeyError):
        raise TypeError("not in the ignore list")
except TypeError as e:
    print(f"TypeError propagated: {e}")
Expected Output:
still running after the missing file
Attempt 1 failed: connection refused (try 1)
Attempt 2 failed: connection refused (try 2)
risky_operation succeeded
KeyError was ignored
TypeError propagated: not in the ignore list
Warning: Returning True unconditionally from __exit__() suppresses ALL exceptions, including KeyboardInterrupt and SystemExit. Always check exc_type before suppressing.

Generator-Based Context Managers

The @contextmanager decorator from contextlib lets you create context managers using generator functions. Code before yield runs on entry, code after runs on exit.

from contextlib import contextmanager
import time
import os

@contextmanager
def timer(name):
    """Time a code block and print duration"""
    start = time.time()
    print(f"Starting {name}...")
    try:
        yield  # Control returns to 'with' block here
    finally:
        duration = time.time() - start
        print(f"{name} took {duration:.2f} seconds")

# Usage
with timer("Data processing"):
    # Your code here
    time.sleep(1)

@contextmanager
def temporary_change(obj, attr, new_value):
    """Temporarily change an attribute, then restore it"""
    original = getattr(obj, attr)
    setattr(obj, attr, new_value)
    try:
        yield obj
    finally:
        setattr(obj, attr, original)

# Usage
from types import SimpleNamespace
config = SimpleNamespace(debug=False)
with temporary_change(config, 'debug', True):
    print(f"inside: debug={config.debug}")
print(f"restored: debug={config.debug}")

@contextmanager
def atomic_write(path):
    """Write to temp file, rename on success"""
    temp_path = f"{path}.tmp"
    f = open(temp_path, 'w')
    try:
        yield f
        f.close()
        os.rename(temp_path, path)  # Atomic on Unix
    except Exception:
        f.close()
        os.remove(temp_path)
        raise

# Usage
with atomic_write('config.json') as f:
    f.write('{"ok": true}')

with open('config.json') as f:
    print(f"config.json holds: {f.read()}")

# A failure leaves no half-written file behind
try:
    with atomic_write('report.json') as f:
        f.write('{"partial": ')
        raise ValueError("ran out of data")
except ValueError:
    pass
print(f"report.json exists: {os.path.exists('report.json')}")
print(f"report.json.tmp exists: {os.path.exists('report.json.tmp')}")
Expected Output:
Starting Data processing...
Data processing took 1.00 seconds
inside: debug=True
restored: debug=False
config.json holds: {"ok": true}
report.json exists: False
report.json.tmp exists: False
Best Practice: Prefer @contextmanager for most cases. Use class-based context managers only when you need to maintain state or implement reusable context manager objects.

Managing Dynamic Contexts: ExitStack

ExitStack manages an arbitrary number of context managers dynamically, ensuring all are properly cleaned up even if errors occur during setup.

import json
import shutil
import tempfile
import threading
from contextlib import ExitStack, contextmanager
from pathlib import Path

work = Path(tempfile.mkdtemp())
(work / "a.json").write_text('{"debug": true}')
(work / "b.json").write_text('{"retries": 3}')

def process_files(file_paths):
    """Process multiple files with guaranteed cleanup"""
    with ExitStack() as stack:
        # Dynamically add context managers
        files = [stack.enter_context(open(p)) for p in file_paths]

        # All files guaranteed to close, even if the loop fails partway
        return [len(f.read()) for f in files]

def merge_configs(*config_paths):
    """Merge multiple config files safely"""
    with ExitStack() as stack:
        configs = [
            json.load(stack.enter_context(open(p)))
            for p in config_paths
        ]
        return {k: v for config in configs for k, v in config.items()}

print(f"bytes read: {process_files([work / 'a.json', work / 'b.json'])}")
print(f"merged: {merge_configs(work / 'a.json', work / 'b.json')}")

# Conditional resource management
@contextmanager
def conditional_lock(lock, should_lock):
    """Optionally acquire a lock"""
    with ExitStack() as stack:
        if should_lock:
            stack.enter_context(lock)
        yield

lock = threading.Lock()
with conditional_lock(lock, should_lock=True):
    print(f"locked inside: {lock.locked()}")
print(f"locked after: {lock.locked()}")

with conditional_lock(lock, should_lock=False):
    print(f"never locked: {lock.locked()}")

# Callback registration for custom cleanup
with ExitStack() as stack:
    # Register arbitrary cleanup functions, run in reverse order on exit
    stack.callback(print, "Cleaning up...")
    stack.callback(shutil.rmtree, work, ignore_errors=True)

    # Do work...
    print(f"work dir exists: {work.exists()}")

print(f"work dir exists after: {work.exists()}")
Expected Output:
bytes read: [15, 14]
merged: {'debug': True, 'retries': 3}
locked inside: True
locked after: False
never locked: False
work dir exists: True
Cleaning up...
work dir exists after: False
Pro Tip: ExitStack is essential when the number of resources isn't known until runtime, such as processing user-provided file lists or managing database connection pools.

Real-World Context Manager Patterns

1. Database Transactions

from contextlib import contextmanager


class Connection:
    """Stand-in for a real DB connection so the example runs on its own"""

    def begin(self):
        print("BEGIN")

    def execute(self, sql):
        print(f"  {sql}")

    def commit(self):
        print("COMMIT")

    def rollback(self):
        print("ROLLBACK")


@contextmanager
def transaction(db_connection):
    """ACID transaction with automatic rollback on error"""
    db_connection.begin()
    try:
        yield db_connection
        db_connection.commit()
    except Exception:
        db_connection.rollback()
        raise

db = Connection()

# Usage
with transaction(db) as conn:
    conn.execute("INSERT INTO users ...")
    conn.execute("UPDATE accounts ...")
    # Automatically commits if successful, rolls back if error

# The same manager rolls back when the block raises
try:
    with transaction(db) as conn:
        conn.execute("INSERT INTO orders ...")
        raise RuntimeError("constraint violated")
except RuntimeError as e:
    print(f"propagated: {e}")
Expected Output:
BEGIN
  INSERT INTO users ...
  UPDATE accounts ...
COMMIT
BEGIN
  INSERT INTO orders ...
ROLLBACK
propagated: constraint violated

2. Thread Safety & Locks

import threading
from contextlib import contextmanager


class SharedResource:
    """Stand-in for whatever the critical section protects"""

    def __init__(self):
        self.value = 0

    def modify(self):
        self.value += 1


shared_resource = SharedResource()

# Standard library locks are context managers
lock = threading.Lock()

with lock:
    # Critical section - guaranteed to release lock
    shared_resource.modify()

print(f"value: {shared_resource.value}, still locked: {lock.locked()}")

# Timeout-aware locking
@contextmanager
def timeout_lock(lock, timeout):
    acquired = lock.acquire(timeout=timeout)
    if not acquired:
        raise TimeoutError("Could not acquire lock")
    try:
        yield
    finally:
        lock.release()

with timeout_lock(lock, 1.0):
    print(f"inside timeout_lock, locked: {lock.locked()}")

# Hold the lock elsewhere and the timeout fires instead of blocking forever
lock.acquire()
try:
    with timeout_lock(lock, 0.1):
        pass
except TimeoutError as e:
    print(f"TimeoutError: {e}")
finally:
    lock.release()
Expected Output:
value: 1, still locked: False
inside timeout_lock, locked: True
TimeoutError: Could not acquire lock

3. Temporary State Changes

import os
import tempfile
from contextlib import contextmanager

@contextmanager
def chdir(path):
    """Temporarily change working directory"""
    original = os.getcwd()
    try:
        os.chdir(path)
        yield
    finally:
        os.chdir(original)

@contextmanager
def env_var(key, value):
    """Temporarily set environment variable"""
    old_value = os.environ.get(key)
    os.environ[key] = value
    try:
        yield
    finally:
        if old_value is None:
            del os.environ[key]
        else:
            os.environ[key] = old_value

started_in = os.getcwd()
elsewhere = tempfile.mkdtemp()

with chdir(elsewhere):
    print(f"inside chdir:  {os.getcwd() == os.path.realpath(elsewhere)}")
print(f"back where we started: {os.getcwd() == started_in}")

print(f"DEPLOY_ENV set before: {'DEPLOY_ENV' in os.environ}")
with env_var('DEPLOY_ENV', 'staging'):
    print(f"inside env_var: {os.environ['DEPLOY_ENV']}")
print(f"DEPLOY_ENV set after:  {'DEPLOY_ENV' in os.environ}")
Expected Output:
inside chdir:  True
back where we started: True
DEPLOY_ENV set before: False
inside env_var: staging
DEPLOY_ENV set after:  False

4. Logging & Monitoring

import logging
import time
from contextlib import contextmanager

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("pipeline")

@contextmanager
def log_execution(operation_name, logger):
    """Log operation execution with timing"""
    logger.info(f"Starting {operation_name}")
    start = time.time()
    try:
        yield
        duration = time.time() - start
        logger.info(f"{operation_name} succeeded in {duration:.2f}s")
    except Exception as e:
        duration = time.time() - start
        logger.error(f"{operation_name} failed after {duration:.2f}s: {e}")
        raise

def extract_data():
    pass

def transform_data():
    pass

def load_data():
    raise ConnectionError("warehouse unreachable")

with log_execution("ETL pipeline", logger):
    extract_data()
    transform_data()

# The failure path logs at ERROR and still re-raises
try:
    with log_execution("nightly load", logger):
        load_data()
except ConnectionError:
    # Everything here goes through logging, so there is one stream and one
    # ordering. Mixing print (stdout) with logging (stderr) would interleave
    # differently depending on whether you are on a terminal or a pipe.
    logger.warning("caller still sees the exception")
Expected Output:
INFO Starting ETL pipeline
INFO ETL pipeline succeeded in 0.00s
INFO Starting nightly load
ERROR nightly load failed after 0.00s: warehouse unreachable
WARNING caller still sees the exception

Protocols & Structural Typing

Protocols define behavioral contracts through structure rather than inheritance. Any object with the right methods satisfies the protocol, enabling flexible, duck-typed interfaces.

import io
from typing import Protocol, runtime_checkable

@runtime_checkable
class SupportsContextManager(Protocol):
    """Protocol for context manager objects"""
    def __enter__(self) -> object: ...
    def __exit__(self, exc_type, exc_value, traceback) -> bool | None: ...

# Any object with these methods is a context manager
def use_resource(resource: SupportsContextManager):
    with resource:
        return "used it"

# Custom protocol for closeable objects
@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

def ensure_closed(obj: Closeable):
    """Works with files, sockets, database connections, etc."""
    try:
        return obj.read()
    finally:
        obj.close()

# Protocol for readable objects
class Readable(Protocol):
    def read(self, size: int = -1) -> bytes: ...

def process_stream(stream: Readable):
    """Works with files, network streams, BytesIO, etc."""
    return stream.read().upper()

# BytesIO never inherits from any of these, it just has the methods
stream = io.BytesIO(b"payload")
print(f"BytesIO is Closeable: {isinstance(stream, Closeable)}")
print(f"int is Closeable: {isinstance(42, Closeable)}")
print(f"BytesIO is a context manager: {isinstance(stream, SupportsContextManager)}")

print(f"process_stream: {process_stream(stream)}")
print(f"use_resource: {use_resource(io.BytesIO(b'x'))}")

closed_it = io.BytesIO(b"drain me")
print(f"ensure_closed returned: {ensure_closed(closed_it)}")
print(f"and closed it: {closed_it.closed}")
Expected Output:
BytesIO is Closeable: True
int is Closeable: False
BytesIO is a context manager: True
process_stream: b'PAYLOAD'
use_resource: used it
ensure_closed returned: b'drain me'
and closed it: True
Design Principle: Protocols enable "structural subtyping", if it walks like a duck and quacks like a duck, it's a duck. This is more flexible than inheritance-based typing and is the foundation of Python's duck typing philosophy.

Async Context Managers

For async code, use __aenter__ and __aexit__ with async with statements. This is essential for async I/O operations like network requests or async database connections.

import asyncio
import time
from contextlib import asynccontextmanager

# Class-based async context manager
class AsyncConnection:
    async def connect(self):
        await asyncio.sleep(0)

    async def close(self):
        await asyncio.sleep(0)

    async def query(self, sql):
        await asyncio.sleep(0)
        return f"2 rows from {sql}"

    async def __aenter__(self):
        await self.connect()
        print("Connection established")
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        await self.close()
        print("Connection closed")
        return False

# Generator-based async context manager
@asynccontextmanager
async def async_timer(name):
    """Async version of timer"""
    start = time.time()
    print(f"Starting {name}...")
    try:
        yield
    finally:
        duration = time.time() - start
        print(f"{name} took {duration:.2f} seconds")

# Async database transaction
@asynccontextmanager
async def async_transaction(db):
    await db.begin()
    try:
        yield db
        await db.commit()
    except Exception:
        await db.rollback()
        raise

# Real-world example: connection pooling
@asynccontextmanager
async def get_connection(pool):
    """Get connection from pool, return on exit"""
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await pool.release(conn)

class Pool:
    """Stand-in for a real pool so the example runs on its own"""

    def __init__(self):
        self.in_use = 0

    async def acquire(self):
        self.in_use += 1
        return AsyncConnection()

    async def release(self, conn):
        self.in_use -= 1

class AsyncDB:
    async def begin(self):
        print("BEGIN")

    async def commit(self):
        print("COMMIT")

    async def rollback(self):
        print("ROLLBACK")

async def main():
    async with AsyncConnection() as conn:
        print(await conn.query("users"))

    async with async_timer("API call"):
        await asyncio.sleep(0.1)

    try:
        async with async_transaction(AsyncDB()):
            raise RuntimeError("deadlock detected")
    except RuntimeError as e:
        print(f"propagated: {e}")

    pool = Pool()
    async with get_connection(pool) as conn:
        print(f"connections in use: {pool.in_use}")
    print(f"connections in use after: {pool.in_use}")

asyncio.run(main())
Expected Output:
Connection established
2 rows from users
Connection closed
Starting API call...
API call took 0.10 seconds
BEGIN
ROLLBACK
propagated: deadlock detected
connections in use: 1
connections in use after: 0
Important: Never mix sync and async context managers. Use async with for async context managers and with for synchronous ones. Attempting to use with on an async context manager will fail.

Common Pitfalls & Anti-Patterns

1. Cleanup That Skips on Failure

import tempfile
from contextlib import contextmanager

# BAD: Not exception-safe
@contextmanager
def bad_file_handler(path):
    f = open(path)
    yield f
    f.close()  # Skipped if exception in with block!

# GOOD: Always use try/finally
@contextmanager
def good_file_handler(path):
    f = open(path)
    try:
        yield f
    finally:
        f.close()  # Always executes

path = tempfile.mkstemp()[1]

for factory in (bad_file_handler, good_file_handler):
    try:
        with factory(path) as f:
            raise ValueError("boom")
    except ValueError:
        pass
    print(f"{factory.__name__:18} left the file open: {not f.closed}")
Expected Output:
bad_file_handler   left the file open: True
good_file_handler  left the file open: False

2. Suppressing Every Exception

# BAD: Suppressing all exceptions
class DangerousContext:
    def __enter__(self):
        return self

    def __exit__(self, *args):
        return True  # Silences KeyboardInterrupt, SystemExit!

# GOOD: Selective suppression
class SafeContext:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            return False
        # Only suppress expected exceptions
        return issubclass(exc_type, (ValueError, KeyError))

# A blanket "return True" swallows the one exception you must never swallow
with DangerousContext():
    raise KeyboardInterrupt("user pressed ctrl-c")
print("DangerousContext swallowed a KeyboardInterrupt")

with SafeContext():
    raise ValueError("expected, and on the list")
print("SafeContext suppressed the ValueError")

try:
    with SafeContext():
        raise KeyboardInterrupt("user pressed ctrl-c")
except KeyboardInterrupt:
    print("SafeContext let the KeyboardInterrupt through")
Expected Output:
DangerousContext swallowed a KeyboardInterrupt
SafeContext suppressed the ValueError
SafeContext let the KeyboardInterrupt through
Note what just happened: the first block swallowed a KeyboardInterrupt. A user hitting Ctrl-C would have been ignored, and the program would have carried on regardless.

3. Stateful Generator Managers

from contextlib import contextmanager

# BAD: Stateful generator context manager
@contextmanager
def bad_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
    yield increment
    print(f"Final count: {count}")

# GOOD: Use class for stateful context managers
class Counter:
    def __init__(self):
        self.count = 0

    def __enter__(self):
        return self

    def increment(self):
        self.count += 1

    def __exit__(self, *args):
        print(f"Final count: {self.count}")
        return False

# One generator drives one 'with' block, so the object cannot be reused
cm = bad_counter()
with cm as inc:
    inc()
try:
    with cm as inc:
        inc()
except AttributeError as e:
    print(f"reuse failed: {type(e).__name__}: {e}")

# The class-based one carries its state and can be entered again
counter = Counter()
with counter:
    counter.increment()
with counter:
    counter.increment()
Expected Output:
Final count: 1
reuse failed: AttributeError: '_GeneratorContextManager' object has no attribute 'args'
Final count: 1
Final count: 2
Why it fails: @contextmanager wraps one generator, and __enter__ consumes it. Entering the same object twice does not raise a helpful error, it raises AttributeError: ... has no attribute 'args' from inside contextlib. A class keeps its state and can be entered as often as you like.

Key Takeaways

  • Context managers encode lifecycle guarantees through __enter__ and __exit__
  • Always use try/finally in __exit__ to ensure cleanup happens
  • Prefer @contextmanager decorator for simplicity, classes for stateful resources
  • Use ExitStack for dynamic or conditional resource management
  • Exception suppression is powerful but dangerous - always check exc_type
  • Protocols enable duck-typed interfaces without inheritance
  • Async context managers (async with) are essential for async I/O
  • Context managers compose cleanly and make error-prone resource management safe by default
What's Next?

You've mastered context managers! Now let's explore C extensions to achieve maximum performance by integrating with C/C++ code.

  • Cython - Write C extensions with Python-like syntax
  • ctypes & CFFI - Call C libraries from Python
  • Performance Optimization - Achieve C-level performance in Python