Python

How Python's `with` Statement Works Internally

Understand how Python's context manager protocol powers the `with` statement through `__enter__` and `__exit__` methods, with practical examples and best practices for writing your own context managers.

August 2026 6 min read 9 views 0 hearts

What Happens When You Use with in Python? Let's Look Under the Hood

You've probably used with open("file.txt") as f: countless times. But have you ever wondered what actually makes this work? It's not magic — it's Python's context manager protocol, powered by two special methods: __enter__ and __exit__.

Think of these methods as a safety net for your code. They ensure resources get cleaned up automatically, even when things go wrong. And understanding how they work will make you a better Python developer.

The Contract of with

When you write:

with something() as x:
    # do stuff

Python executes a simple contract:

  1. Call something.__enter__() and assign the result to x
  2. Run the indented code block
  3. Always call something.__exit__(), even if the code raises an error

Let's see this in action with a real-world example from PythonSkillset's file processing scripts:

class ManagedFile:
    def __init__(self, filename):
        self.filename = filename

    def __enter__(self):
        self.file = open(self.filename, 'r')
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()
        # Return False to propagate any exception (default behavior)
        return False

# Usage
with ManagedFile('data.txt') as f:
    content = f.read()

This is exactly what Python's built-in open() does — but now you can see the mechanics.

Why Three Parameters in __exit__?

The __exit__ method receives three arguments that tell you what happened:

  • exc_type: The exception class (like ValueError or TypeError), or None if no error occurred
  • exc_val: The actual exception instance, or None
  • exc_tb: The traceback object, or None

This gives you fine-grained control. Here's a practical example from a database connection manager at PythonSkillset:

class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name
        self.connection = None

    def __enter__(self):
        self.connection = connect_to_db(self.db_name)
        return self.connection

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            # Something went wrong - rollback any pending transaction
            print(f"Error occurred: {exc_val}")
            self.connection.rollback()
        else:
            # Everything went smoothly - commit changes
            self.connection.commit()

        self.connection.close()
        # Returning False means re-raise any exception
        # Returning True would suppress the exception
        return False

Common Gotchas and Best Practices

1. Returning True Suppresses Exceptions

This is probably the most surprising behavior. If __exit__ returns True, any exception that occurred in the with block gets swallowed:

class SuppressErrors:
    def __enter__(self):
        return self

    def __exit__(self, *args):
        return True  # All errors disappear!

with SuppressErrors():
    1/0  # This would normally crash, but...
print("We're still running!")  # This executes!

Unless you have a very good reason, always return False or None from __exit__.

2. The as Variable Goes Out of Scope

Inside the with block, the variable from as works fine. But once you exit, that variable may still exist — though the underlying resource is already closed. Don't try to use it afterward.

3. Context Managers Can Be Used Explicitly

You don't have to wait for with. You can call __enter__ and __exit__ directly, though this is rare:

cm = MyContextManager()
cm.__enter__()
try:
    # do stuff
finally:
    cm.__exit__(None, None, None)

A Practical Example: Timing Code Execution

Here's a context manager you might build yourself — measuring how long code takes:

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, *args):
        self.end = time.perf_counter()
        self.elapsed = self.end - self.start
        print(f"Elapsed time: {self.elapsed:.4f} seconds")

with Timer() as timer:
    sum(range(10**6))
# Output: Elapsed time: 0.0352 seconds

When Should You Write Your Own Context Manager?

Think about resources that need setup and teardown: - Database connections (open/close) - File handles (open/close) - Network connections (connect/disconnect) - Lock acquisition (acquire/release) - Temporary file creation (create/delete)

Any time you find yourself writing try/finally blocks, consider wrapping the logic in a context manager.

The contextlib Shortcut

Python's contextlib module provides contextmanager decorator, which lets you write context managers using generators:

from contextlib import contextmanager

@contextmanager
def managed_file(filename):
    f = open(filename, 'r')
    try:
        yield f
    finally:
        f.close()

with managed_file('data.txt') as f:
    content = f.read()

This is perfect for simple cases where you don't need a full class.

Final Thoughts

Context managers are one of Python's most elegant features. They make resource management automatic, code cleaner, and bugs less likely. The next time you write with, you'll know exactly what's happening behind the curtain — and you'll have the power to create your own.

At PythonSkillset, we've seen teams reduce code duplication by 40% just by converting manual try/finally blocks into context managers. It's one of those habits that pays off immediately.

Comments

Questions, corrections, and tips stay visible for everyone reading this page.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.