Python: Context Managers, with Statement & Custom Managers

A Python context manager is the machinery behind the with statement. This post opens it up so you can see exactly how with works under the hood, what the __enter__ and __exit__ methods do, how to build your own context manager with a class or the @contextmanager decorator, and the patterns that stop resource leaks in real apps.

“The cleanup code you never run is the bug you never see coming.”

Every developer who has leaked a file handle

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 18 minutes

Think about parking your car at a paid lot. You take a ticket on the way in, and you hand it back on the way out so the barrier lifts and you stop getting charged. The with statement is that parking ticket. It hands you a resource when you enter, and it guarantees the cleanup runs when you leave, whether you drive out calmly or your code crashes into a wall.

Without context managers, you would have to wrap a try/finally around every file you open, every database connection, every lock you grab. And one day you would forget the finally. Everyone forgets. A file stays open, a connection never closes, and a week later the server falls over because it ran out of handles. The with statement exists so that the cleanup is not your job to remember. Python remembers for you. That is the whole point, and the rest of this post is about how it actually pulls that off.

The Mystery: What Does with Actually Do?

Two ways to createClass-based__enter__ + __exit__@contextmanageryield-based generatorWithout vs Withcontext managerEquivalent butcleanertry:f = open(‘data.txt’)data = f.read()finally:f.close()with open(‘data.txt’) as f:data = f.read()# f.close() is# automatic!with statement executionNoYesNoYes1. Call __enter__()2. Bind return valueto ‘as’ variable3. Execute body block4. Exceptionraised?5a. __exit__(None, None,None)6. Continue execution5b. __exit__(exc_type, exc_val,exc_tb)__exit__ returnsTrue?ExceptionpropagatesExceptionsuppressedPython Context Managers: How the with Statement Runs __enter__ and __exit__

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram follows the whole lifecycle of a Python context manager. Python calls __enter__ the moment execution steps into the with block, runs your code in the body, and then calls __exit__ on the way out. That exit call happens even if an exception was raised in the middle. The __exit__ method gets the exception details handed to it, so it can decide to swallow the error or let it keep travelling up. This guaranteed cleanup is exactly why with is the normal way to deal with files, database connections, locks, and anything else that has to be closed properly.

📄 the_mystery.py: these two blocks do the same thing

# What you write:
with open("data.txt") as f:
    content = f.read()
# f is closed for you right here, even if f.read() raised an error

# What Python actually does:
manager = open("data.txt")
f = manager.__enter__()     # Step 1: grab the resource
try:
    content = f.read()      # Step 2: run the body
except Exception as exc:
    if not manager.__exit__(type(exc), exc, exc.__traceback__):
        raise               # Step 3b: clean up, then re-raise if not suppressed
else:
    manager.__exit__(None, None, None)  # Step 3a: clean up, no error happened

What happened here: The with statement is really just a protocol with two methods. __enter__ grabs the resource and hands it back, and that returned value is what lands in the as variable. __exit__ releases the resource, and it is guaranteed to run whether the body finished cleanly or blew up. One detail to lock in now, because the rest of the post leans on it: if __exit__ returns True, the exception is swallowed and your program carries on.

If it returns False (or nothing, which counts as False), the exception keeps propagating like normal. The expanded version in the second half of the snippet is not real code you would write, it is the rough shape of what Python runs for you behind that one clean with line.

Step by Step: The Context Manager Protocol

Enough theory. Let us build a real one. Think of it like a hotel stay: check-in hands you the key card, check-out takes it back, and housekeeping resets the room no matter how you left it. In code, any class becomes a context manager the second you give it an __enter__ method (the check-in) and an __exit__ method (the check-out). That is the entire contract. Here is a class that pretends to open a database connection and looks up records for a user named Rahul, so you can watch exactly when each method fires.

📄 protocol.py: a context manager built from scratch

class DatabaseConnection:
    """Manages database connections with guaranteed cleanup."""

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

    def __enter__(self):
        print(f"Connecting to {self.db_name}...")
        self.connection = f"Connection({self.db_name})"  # Simulated
        return self  # This becomes the 'as' variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Closing connection to {self.db_name}")
        self.connection = None
        if exc_type is not None:
            print(f"Error occurred: {exc_val}")
        return False  # Don't suppress exceptions

# Usage: the connection ALWAYS closes, even when something goes wrong
with DatabaseConnection("users_db") as db:
    print(f"Using {db.connection}")
    print("Querying Rahul's records...")
    # Even if an exception happens here, __exit__ still runs

print(f"After with block: connection = {db.connection}")

▶ Output

Connecting to users_db...
Using Connection(users_db)
Querying Rahul's records...
Closing connection to users_db
After with block: connection = None

What happened here: Read the output top to bottom and you can see the order Python ran things in. __enter__ printed the “Connecting” line and returned self, which is why db inside the block is the connection object. The body ran your two prints. Then, the second the block ended, Python called __exit__, which printed “Closing connection” and set the connection back to None. Notice the last line: after the with block, db.connection is already None. You did not write a single line of cleanup at the call site, and yet the connection is gone. That is the context manager doing the remembering for you.

The Easy Way: @contextmanager

Writing a full class with __enter__ and __exit__ for every little resource gets tiring fast. The contextlib.contextmanager decorator lets you write the same behavior as a short generator function with one yield in the middle. Picture a recipe with a single “let it rest here” step: everything above that line is your prep, everything below it is the cleanup, and the yield is the pause where the caller gets to do their thing. The demo times a data-crunching job for a user named Anvi.

📄 contextmanager_decorator.py: same behavior, half the code

from contextlib import contextmanager

@contextmanager
def timer(label):
    """Time a block of code."""
    import time
    start = time.perf_counter()
    print(f"[{label}] Starting...")
    try:
        yield  # Everything before yield = __enter__, after = __exit__
    finally:
        elapsed = time.perf_counter() - start
        print(f"[{label}] Finished in {elapsed:.4f}s")

# Usage
with timer("Processing Anvi's data"):
    total = sum(range(1_000_000))
    print(f"Sum: {total}")

▶ Output

[Processing Anvi's data] Starting...
Sum: 499999500000
[Processing Anvi's data] Finished in 0.0186s

What happened here: Everything written before the yield is your __enter__, and it ran first to print the “Starting” line and record the start time. The yield handed control back to the with block, which summed a million numbers. Then the part after yield ran as your __exit__ and printed the elapsed time. The try/finally is what makes this safe: even if the body had crashed, the finally would still print the timing, so you never lose your cleanup.

The exact number you see (here 0.0186s) will differ on your machine and from run to run, and that is expected for a timer. If you ever need to pass a value out to the as clause, you just write yield the_value, which is exactly what the next example does.

Yielding a Value

So far the timer yielded nothing. Most of the time you want to hand something back, the way open() hands you a file object. It works like a locker at a swimming pool: the attendant assigns you a locker and hands you the key, and when you leave, the locker gets emptied out for the next person. Whatever you put after yield is that key, and it becomes the as variable. Here is a context manager that creates a throwaway directory, gives you its path to work in, and then wipes it clean when you are done. In the demo, a user named Anvay drops a quarterly report into it.

📄 yield_value.py: a temp directory that cleans up after itself

from contextlib import contextmanager
import tempfile
import shutil
import os

@contextmanager
def temporary_directory(prefix="tmp_"):
    """Create a temp directory, yield its path, then delete it."""
    dirpath = tempfile.mkdtemp(prefix=prefix)
    print(f"Created: {dirpath}")
    try:
        yield dirpath  # This value binds to the 'as' variable
    finally:
        shutil.rmtree(dirpath)
        print(f"Cleaned up: {dirpath}")

with temporary_directory(prefix="anvay_") as tmpdir:
    filepath = os.path.join(tmpdir, "report.txt")
    with open(filepath, "w") as f:
        f.write("Anvay's quarterly report")
    print(f"File exists: {os.path.exists(filepath)}")

print(f"Dir exists after with: {os.path.exists(tmpdir)}")

▶ Output

Created: /tmp/anvay_8f3kd1qz
File exists: True
Cleaned up: /tmp/anvay_8f3kd1qz
Dir exists after with: False

What happened here: On entry, tempfile.mkdtemp() made a brand new directory and yield dirpath handed that path to tmpdir. Inside the block you wrote a file into it, and os.path.exists confirmed the file was really there. The instant the block ended, the code after yield ran, shutil.rmtree deleted the whole directory, and the final check came back False. The random suffix on the path (8f3kd1qz here) is generated fresh every run, so yours will look different.

The folder name also depends on your operating system: on Linux and macOS you get a /tmp/... path like this one, while on Windows it lands under your user’s AppData\Local\Temp folder. The behavior is identical either way, the directory is gone the moment you leave the block.

Exception Handling in __exit__

Here is where context managers do something genuinely surprising. They can catch an exception for you. It works like the spam filter on your inbox: junk you have specifically told it about gets dropped quietly, but everything else still lands in front of you. Remember the rule from earlier: if __exit__ returns True, the error is swallowed and your program keeps going as if nothing happened. This is how contextlib.suppress works, and you can build the same thing yourself. In the second half of the example, a user named Aditi has no email on file, and the lookup error gets suppressed.

📄 suppress_exception.py: a context manager that swallows errors

class SuppressErrors:
    """Suppress specific exception types, like contextlib.suppress()."""

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

    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):
            print(f"Suppressed: {exc_type.__name__}: {exc_val}")
            return True  # Suppress the exception
        return False     # Let other exceptions propagate

with SuppressErrors(FileNotFoundError, PermissionError):
    open("aviraj_nonexistent_file.txt")  # FileNotFoundError, gets suppressed

print("Code continues normally after suppressed exception")

# Python ships this built-in: from contextlib import suppress
from contextlib import suppress

with suppress(KeyError):
    data = {"name": "Aditi"}
    print(data["email"])  # KeyError, suppressed silently with no output

▶ Output

Suppressed: FileNotFoundError: [Errno 2] No such file or directory: 'aviraj_nonexistent_file.txt'
Code continues normally after suppressed exception

What happened here: Opening a file that does not exist normally raises FileNotFoundError and stops your program. But this with block caught it. When the error reached __exit__, the code checked whether the type matched one of the exceptions you cared about, printed the “Suppressed” line, and returned True. That True is the signal that means “I handled it, do not re-raise”. The second block uses the real built-in, contextlib.suppress(KeyError), and notice it prints nothing at all.

The missing "email" key would normally raise KeyError, but suppress swallowed it silently, which is why there is no third line in the output. Use this power sparingly. Swallowing errors you did not expect is how bugs hide for months.

Multiple Context Managers

Real tasks often need more than one resource at a time: read from one file, write to another. You do not have to nest with blocks five levels deep. Python lets you line them up, and for the cases where you do not know the count ahead of time, contextlib.ExitStack handles a whole pile of them. In the second example, picture a developer named Viraj stamping his name into several log files at once.

📄 multiple.py: stacking several context managers

# Python 3.10+ parenthesized context managers
with (
    open("input.txt") as src,
    open("output.txt", "w") as dst,
):
    dst.write(src.read())

# When you do not know the count up front, use contextlib.ExitStack
from contextlib import ExitStack

filenames = ["log1.txt", "log2.txt", "log3.txt"]
with ExitStack() as stack:
    files = [stack.enter_context(open(f, "w")) for f in filenames]
    for i, f in enumerate(files):
        f.write(f"Log entry {i} by Viraj\n")
# All files close automatically when the stack exits

What happened here: The parenthesized form (available since Python 3.10) opens both files on one tidy line, and both close on the way out, in reverse order. The neat part is the safety net: if the second open fails, the first file still closes, so you never leak a handle. The ExitStack version solves a different problem. When the number of files is decided at runtime, like a list you read from config, you cannot write a fixed number of as clauses.

You push each one onto the stack with enter_context, and when the stack exits it unwinds every context it collected. Think of it like a coat check: you keep handing in coats one at a time, get a ticket for each, and at the end the whole rack gets cleared at once.

Build It Yourself: A Tiny with

The fastest way to truly believe how with works is to skip the keyword and call the protocol by hand. It is like taking the back off a watch: once you have seen the gears turn, the ticking stops being mysterious. Below is a context manager that grabs a lock, and right under it we drive the same object manually, the way Python would. No magic, just two method calls and a try/finally.

📄 build_it.py: running the protocol without the with keyword

class Lock:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"Acquiring lock: {self.name}")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Releasing lock: {self.name}")
        return False  # do not suppress anything

# The normal way:
print("--- with statement ---")
with Lock("db_write") as lock:
    print(f"Working safely inside {lock.name}")

# Exactly what 'with' expands to, written by hand:
print("--- manual version ---")
mgr = Lock("db_write")
resource = mgr.__enter__()
try:
    print(f"Working safely inside {resource.name}")
finally:
    mgr.__exit__(None, None, None)

▶ Output

--- with statement ---
Acquiring lock: db_write
Working safely inside db_write
Releasing lock: db_write
--- manual version ---
Acquiring lock: db_write
Working safely inside db_write
Releasing lock: db_write

What happened here: Both halves print the same three lines, because they do the same thing. The with statement is not a special feature buried deep in the interpreter, it is a shorthand for “call __enter__, run the body inside a try, and call __exit__ in a finally“. Once you have written the manual version once, the keyword stops feeling like magic. You can see precisely where cleanup is guaranteed: it sits in the finally, so it runs no matter how the body ends.

When You Will Reach For This

Context managers are not an academic curiosity. You will hit these situations in real code, probably this week:

  • Files and downloads. Every time you read a CSV (Comma-Separated Values) file, write a log, or save an uploaded file, with open(...) makes sure the handle closes even if parsing blows up halfway through a million rows.
  • Database connections and transactions. Libraries like sqlite3 and SQLAlchemy use with so a connection commits on success and rolls back on error, then closes either way. No half-finished writes left behind.
  • Locks in threaded code. A with some_lock: block grabs the lock on entry and releases it on exit. Forget to release a lock by hand and you get a deadlock that freezes your whole program. The context manager will not forget.
  • Temporary setup that must be undone. Changing the working directory, bumping decimal precision, opening a network socket, starting a timer. Anything with a “do this, then always undo it” shape fits the pattern perfectly.

The common thread is simple: whenever there is a setup step that must be paired with a teardown step, a context manager keeps the two glued together so you cannot run one without the other.

Common Misconceptions

These are not typos or syntax slips. They are the wrong ideas people carry in their head about how context managers behave. Clear them up now and the rest stays easy.

❌ Misconception: “with only works with files”

# Context managers work with ANY resource that needs cleanup:
# - Files (open)
# - Database connections (sqlite3.connect)
# - Thread locks (threading.Lock)
# - Network sockets
# - Temporary directories (tempfile.TemporaryDirectory)
# - Decimal precision (decimal.localcontext)
# - Suppressing exceptions (contextlib.suppress)
# Anything with __enter__ and __exit__ works with 'with'.

Why this trips people up: Beginners meet with through open() and assume that is all it does. But with never cared about files. It only cares about the two methods. Anything that has an __enter__ and an __exit__ is fair game, which is why locks, connections, and timers all use the same clean syntax.

❌ Misconception: “returning True from __exit__ is normal”

# Returning True from __exit__ SUPPRESSES the exception.
# This is almost never what you want.
# Most context managers should return False (or None, which counts as False).
# Only return True when you are deliberately building an error-suppression tool
# like contextlib.suppress.

Why this trips people up: The return value of __exit__ feels harmless, so people leave it off or return True without thinking. But True means “hide this exception”, and a hidden exception is a bug that no one can see. When you are not building a suppression tool on purpose, return False (or just return nothing, which Python reads as False) so real errors still reach you.

Conclusion

So that is the whole story. A context manager is just an object with __enter__ and __exit__, and the with statement calls them for you: __enter__ at the start, __exit__ at the end, every single time, no matter what happens in between. The @contextmanager decorator lets you write the short generator version with one yield, and the return value of __exit__ decides whether an exception gets swallowed or passed along. Keep coming back to the parking-ticket picture: take the ticket on the way in, hand it back on the way out, and let Python lift the barrier for you.

Context managers handle the lifecycle of a resource. Regular expressions handle the shape of text. Up next in the regular expressions tutorial, you will get hands on with Python’s re module, the standard tool for matching, searching, and reshaping text with pattern syntax. And if you want to jump around or see the full roadmap from beginner to AI/ML, everything lives at the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is a context manager in Python?

A Python context manager is any object that implements the __enter__ and __exit__ methods. It defines the setup and teardown logic that the with statement runs for you. The most common example is open() for files, which closes the file automatically when the block ends.

What is the difference between __enter__ and __exit__?

__enter__ runs when you enter the with block: it grabs the resource and returns the value bound to as. __exit__ runs when you leave the block, whether the body succeeded or raised an error: it releases the resource. __exit__ also receives the exception info (or None when there was no error) as parameters.

How does @contextmanager work?

@contextmanager from contextlib turns a generator function into a context manager. The code before yield acts as __enter__, and the code after yield acts as __exit__. The value you yield becomes the as variable. Wrap the yield in try/finally so cleanup is guaranteed.

Can __exit__ suppress exceptions?

Yes. If __exit__ returns True, the exception is swallowed and execution continues after the with block. If it returns False (or None), the exception keeps propagating. Use suppression on purpose only. contextlib.suppress is the standard tool when you genuinely want to ignore a specific error.

What is ExitStack used for?

contextlib.ExitStack manages a number of context managers that you do not know at write time. Instead of nesting many with statements, you push each context onto the stack and they all clean up when the stack exits. It is the go to when the count of resources is decided at runtime.

Try It Yourself

Build a @contextmanager called change_directory(path) that hops into a folder for the length of a block, then puts you back where you started. Inside with change_directory("/tmp"): ... the current directory should be path, and the moment the block ends you should be back in the original directory, even if the body raises an error. Hint: save os.getcwd() before you os.chdir(path), then restore it in a finally. That finally is the part that survives a crash, so do not skip it.

Interview Questions on Python Context Managers

These come from real screens and onsites. Practice answering before you read each answer.

Q: Your web service has been up for a week, and it suddenly starts failing with “Too many open files” errors. What do you check first?

Look for file handles and sockets that are opened but never closed: any open(), socket, or connection call that is not wrapped in a with block and has no close() in a finally. The usual culprit is an exception raised between open and close, which skips the close and leaks one handle per request until the OS limit is hit. Wrap each acquisition in a context manager so cleanup is guaranteed, and confirm the leak with a tool like lsof or by watching the process’s open descriptor count over time.

Q: If __enter__ itself raises an exception, does __exit__ still run?

No. The with statement only guarantees __exit__ after __enter__ has returned successfully. If __enter__ raises, the body never starts and __exit__ is never called, so any partial setup done before the failure must be cleaned up inside __enter__ itself, typically with its own try/except that undoes the partial work before re-raising.

Q: You wrote a @contextmanager generator, and you notice the cleanup code after yield never runs when the body raises an error. Why?

Because the exception from the body is re-raised inside the generator at the yield line. If the yield is not wrapped in try/finally, execution leaves the generator right there and the lines after yield are skipped. The fix is the pattern this post used everywhere: put yield inside a try block and the cleanup inside finally, so it runs on both the happy path and the error path.

Q: Is __exit__ guaranteed to run in absolutely every situation?

Not every situation. It is guaranteed for normal completion and for exceptions raised in the body, but nothing in Python survives the process dying abruptly: os._exit(), a kill -9, or a power cut all skip __exit__ entirely. That is why systems that must never lose data, like databases, pair context managers with durable mechanisms such as transaction logs instead of relying on in-process cleanup alone.

Q: A with statement is just try/finally under the hood, so when would you still write a raw try/finally?

Use with when the setup-and-teardown pair is reusable or already provided by a library, because it packages the pairing so callers cannot forget it. A raw try/finally still makes sense for a one-off cleanup that will never be reused, or when acquisition and release cannot live in the same block, for example a resource acquired in one method and released in another. In that second case, contextlib.ExitStack with pop_all() is often the cleaner middle ground.

Q: What is an async context manager, and when do you need one?

An async context manager implements __aenter__ and __aexit__, which are coroutines, and you use it with async with inside an async def function. You need it when the setup or teardown itself must await something, like opening an aiohttp client session or an async database connection. There is also an @asynccontextmanager decorator in contextlib that mirrors the generator style you saw in this post.

Want more? the official Python documentation documents everything this post could not fit.

Previous: Python: Decorators Deep Dive, Writing & Real-World Patterns

Next: Python: Regular Expressions, Patterns, Groups, Lookaheads

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *