Filter concepts by levelShowing all levels.

Python · Section 8

Context Managers

Level
intermediate
Read
60 min
Concepts
6

Guaranteed setup and cleanup around a block of code — the with statement, contextlib, nested and async variants, and why the pattern matters for files, transactions, locks, and other resources.

This section

What is true here

  1. with obj as name: runs __enter__ before the block and __exit__ after — even on an exception.
  2. @contextlib.contextmanager turns a generator into a context manager: setup before yield, teardown after.
  3. Nested context managers exit in the reverse of their entry order — last entered, first exited.
  4. async with awaits __aenter__/__aexit__ for setup or teardown that itself needs to await something.

What you will be able to do

  • Explain why with exists and what guarantee it gives over a manual try/finally
  • Write a context manager with @contextlib.contextmanager using try/yield/finally
  • Predict the entry and exit order of multiple nested context managers
  • Write and use an async context manager with __aenter__/__aexit__ and async with
  • Recognize files, database transactions, locks, resources, and temporary state as the same underlying pattern

Writing context managers

The with statement itself, the contextlib module, the generator-based shortcut, and how multiple managers combine — sync and async.

The with statement

corebeginner

with obj: runs obj.__enter__() before the block and obj.__exit__() after — even if the block raises. It replaces a manual try/finally for setup and teardown.

Think of it as

with is a promise that cleanup happens no matter what — like a hotel room key that automatically locks the door when you leave, whether you left normally or through the fire exit. You never have to remember to lock it yourself.

python
with expression as name:
    # name is expression.__enter__()'s return value
    ...
# expression.__exit__() has already run by here, always

What we're doing: Compare a manual try/finally against with for the same open/close guarantee, and confirm the with version closes even though print("opening") and print("closing") never appear out of order.

managed_file.pypython
class ManagedFile:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"opening {self.name}")
        self.file = "FAKE_HANDLE"
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"closing {self.name}")
        return False

with ManagedFile("log.txt") as f:
    print(f"using {f}")
12
with calls ManagedFile("log.txt").__enter__() first, binding its return value to f.
13
The block runs with f already set up — no separate open() call needed.
12–13
__exit__() runs automatically once the block ends, before the next line of the program.
Output
opening log.txt
using FAKE_HANDLE
closing log.txt

Why this works: "closing log.txt" prints right after the block ends, with no explicit call to close anything — __exit__() ran automatically. A hand-written version would need x = ManagedFile("log.txt"); x.__enter__(); try: ... finally: x.__exit__(...) to get the same guarantee, and every caller would have to remember to write the try/finally correctly.

Doing setup/teardown by hand and forgetting the finally

Wrong

python
f = ManagedFile("log.txt")
data = f.__enter__()
print(f"using {data}")
raise ValueError("boom")
f.__exit__(None, None, None)   # never reached — the raise skipped it

Better

python
with ManagedFile("log.txt") as data:
    print(f"using {data}")
    raise ValueError("boom")   # __exit__ still runs before this propagates

What you see: The file handle is never closed — a resource leak that shows up later as 'too many open files' or a stuck lock, not at the line that caused it.

Why: Calling __enter__()/__exit__() directly means a raise between them skips __exit__() entirely, because nothing is protecting the call with try/finally. with builds that protection in, so a raised exception never bypasses cleanup.

with guarantees the closing half runs

__enter__()

setup — return value becomes as name

block body

may complete, or raise

__exit__()

teardown — always runs

  1. __enter__() — setup — return value becomes as name
  2. block body — may complete, or raise
  3. __exit__() — teardown — always runs

Manual try/finally vs with, for the same guarantee

Manual try/finally vs with, for the same guarantee
StyleCode shape
Manual try/finallyx = acquire(); try: use(x) finally: release(x)
with statementwith acquire() as x: use(x)
with, no bound namewith lock: do_work() # __enter__ return value unused
Multiple managerswith a() as x, b() as y: use(x, y)

Together

python
class ManagedFile:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"opening {self.name}")
        self.file = "FAKE_HANDLE"
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"closing {self.name}")
        return False

with ManagedFile("log.txt") as f:
    print(f"using {f}")

Remember: with obj as name: runs __enter__ before the block and __exit__ after — even on an exception. It is try/finally you do not have to write.

See also: context manager dunders · contextlib contextmanager · why context managers matter

The contextlib module

standardintermediate

contextlib is the standard library module for building context managers without a full __enter__/__exit__ class — @contextmanager, suppress(), and closing() are all here.

Think of it as

contextlib is a toolbox of shortcuts for the with-statement protocol — instead of writing a class with __enter__/__exit__ every time, you reach for a ready-made helper that covers the common shape.

python
from contextlib import contextmanager, suppress, closing, ExitStack

with suppress(KeyError):
    ...
with closing(some_obj) as obj:
    ...

contextlib helpers worth knowing

contextlib helpers worth knowing
HelperWhat it does
@contextmanagerTurns a generator function into a context manager (try/yield/finally)
suppress(*exc_types)Ignores the listed exception types if raised inside the block
closing(obj)Calls obj.close() on exit — for objects with close() but no __exit__
ExitStack()Holds a variable number of context managers, all closed in reverse order
nullcontext(value)Does nothing on enter/exit — a placeholder when a manager is optional

Together

python
from contextlib import suppress

with suppress(FileNotFoundError):
    open("does_not_exist.txt")   # error is silently ignored

print("execution continues normally")

Remember: contextlib is the standard library toolbox for context managers — @contextmanager, suppress(), closing(), and ExitStack cover most needs without a full class.

See also: contextlib contextmanager · with statement

@contextlib.contextmanager

coreintermediate

@contextmanager turns a generator function into a context manager. Code before yield runs on entry, and code after yield — wrapped in try/finally — runs on exit.

Think of it as

Think of the generator as split at yield: everything above it is __enter__, everything below it (in a finally) is __exit__. The value passed to yield is what becomes the with block's as name.

python
from contextlib import contextmanager

@contextmanager
def my_manager(arg):
    # setup
    try:
        yield value          # becomes the "as" name
    finally:
        # teardown — always runs, even on exception

What we're doing: Show teardown running even when the with block raises, by wrapping a real try/except around the with statement and confirming the exception still propagates.

resource.pypython
from contextlib import contextmanager

@contextmanager
def managed_resource(name):
    print(f"acquiring {name}")
    try:
        yield name
    finally:
        print(f"releasing {name}")

try:
    with managed_resource("cache-lock") as res:
        print(f"using {res}")
        raise ValueError("something broke inside the block")
except ValueError as e:
    print(f"caught outside: {e}")
5
Code before yield runs immediately when the with block starts — this is the setup half.
7
yield name pauses the generator and hands name to the with block as res.
8
finally guarantees "releasing cache-lock" prints even though the block below raises.
Output
acquiring cache-lock
using cache-lock
releasing cache-lock
caught outside: something broke inside the block

Why this works: "releasing cache-lock" prints before the ValueError reaches the except clause outside — the finally block runs as the exception passes back through the generator, exactly like __exit__ would in a class-based context manager. The exception is not swallowed: it still propagates to the try/except wrapping the with statement.

Putting teardown after yield without a try/finally

Wrong

python
@contextmanager
def managed_resource(name):
    print(f"acquiring {name}")
    yield name
    print(f"releasing {name}")   # SKIPPED if the block raises!

with managed_resource("cache-lock") as res:
    raise ValueError("boom")

Better

python
@contextmanager
def managed_resource(name):
    print(f"acquiring {name}")
    try:
        yield name
    finally:
        print(f"releasing {name}")   # always runs

What you see: "releasing cache-lock" never prints, and the resource is never cleaned up — the exception propagates straight out of the generator, skipping every line after yield.

Why: Without try/finally, an exception raised inside the with block is thrown into the generator at the yield point and propagates immediately — any code written after yield with no protection is simply never reached. finally is not optional decoration here; it is what makes teardown run at all.

yield splits setup from teardown

before yield

setup — runs on entry

yield value

pauses; value becomes as name

after yield (finally)

teardown — always runs

  1. before yield — setup — runs on entry
  2. yield value — pauses; value becomes as name
  3. after yield (finally) — teardown — always runs

The try/yield/finally shape

The try/yield/finally shape
PartRole
Code before yieldRuns on entry — equivalent to __enter__
yield valuePauses here; value becomes the as name
try: ... finally:Wraps the yield so teardown runs even on exception
Code after yield (in finally)Runs on exit — equivalent to __exit__

Together

python
from contextlib import contextmanager

@contextmanager
def managed_resource(name):
    print(f"acquiring {name}")
    resource = f"{name}-handle"
    try:
        yield resource
    finally:
        print(f"releasing {name}")

with managed_resource("db-connection") as conn:
    print(f"using {conn}")

Remember: @contextmanager splits a generator at yield: before is setup, after (in finally) is teardown — no class, no __enter__/__exit__ needed.

See also: with statement · contextlib module · context manager dunders

Nested context managers

standardintermediate

with a() as x, b() as y: enters a then b, and exits b then a — last entered, first exited. It is equivalent to nesting two with blocks.

Think of it as

Nested context managers stack like plates — the last one placed on top is the first one removed. Whatever entered last has to exit first, because it may depend on what came before it still being open.

python
with a() as x, b() as y:
    ...
# exits: b first, then a

What we're doing: Confirm nested context managers enter in written order and exit in reverse, using print statements to capture the real sequence.

nested.pypython
from contextlib import contextmanager

@contextmanager
def step(name):
    print(f"enter {name}")
    try:
        yield name
    finally:
        print(f"exit {name}")

with step("outer") as a, step("inner") as b:
    print(f"body using {a} and {b}")
11
step("outer") enters first, then step("inner") — left to right, as written.
12
The body runs with both resources open, innermost last-acquired.
11–12
On exit, "inner" tears down before "outer" — the reverse of entry order.
Output
enter outer
enter inner
body using outer and inner
exit inner
exit outer

Why this works: "exit inner" prints before "exit outer" even though "enter outer" printed first — each context manager's __exit__ (or, here, the finally after yield) runs in the reverse of entry order. This mirrors a single with block nested inside another: with step("outer") as a: / with step("inner") as b: produces the identical enter/exit sequence, which is exactly what the comma-separated form is shorthand for.

Assuming exit order matches entry order

Wrong

python
# WRONG assumption: "outer exits before inner"
with acquire_lock("outer") as a, acquire_lock("inner") as b:
    ...
# actually: inner's lock releases FIRST, then outer's

Better

python
# Correct: last acquired is first released — same as nesting
with acquire_lock("outer") as a:
    with acquire_lock("inner") as b:
        ...
    # inner released here
# outer released here

What you see: A resource that depends on an earlier one being released first — order-sensitive cleanup, like releasing a lock before closing the connection it protects — tears down in the wrong sequence and fails or deadlocks.

Why: Nested context managers always exit in reverse of entry order, the same way nested function calls return in reverse of how they were entered. Code that assumes first-in-first-out teardown, rather than last-in-first-out, gets the order backwards.

Remember: with a() as x, b() as y: enters a then b, and exits b then a — last entered, first exited, same as nesting them.

See also: with statement · contextlib contextmanager

Async context managers and async with

standardintermediate

async with obj: awaits obj.__aenter__() before the block and obj.__aexit__() after. Use it when setup or teardown itself needs to await something, like a network call.

Think of it as

async with is with for a world where opening or closing a resource is itself a task that takes time — __aenter__ and __aexit__ are coroutines, so the event loop can do other work while they run.

python
class Name:
    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        return False

async def main():
    async with Name() as obj:
        ...

What we're doing: Define an async context manager and confirm __aenter__/__aexit__ actually run as coroutines around the block, executed for real via asyncio.run().

async_conn.pypython
import asyncio

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

    async def __aenter__(self):
        print(f"async opening {self.name}")
        await asyncio.sleep(0)
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        await asyncio.sleep(0)
        print(f"async closing {self.name}")
        return False

async def main():
    async with AsyncConnection("api-session") as conn:
        print(f"using {conn.name}")

asyncio.run(main())
5–9
__aenter__ is a coroutine — it can await other async work (here, asyncio.sleep(0)) before the block starts.
18
async with awaits __aenter__(), binding its return value to conn.
12–15
__aexit__ is awaited automatically once the block ends, before main() continues.
Output
async opening api-session
using api-session
async closing api-session

Why this works: async with AsyncConnection("api-session") awaits __aenter__() before entering the block and awaits __aexit__() after it ends — the same guaranteed-cleanup contract as with, except both hooks are coroutines that can themselves await. This matters for a manager whose open/close genuinely needs to do async work, like a database driver awaiting a network handshake.

Using with instead of async with on an async context manager

Wrong

python
async def main():
    with AsyncConnection("api-session") as conn:   # missing "async"
        print(conn.name)

Better

python
async def main():
    async with AsyncConnection("api-session") as conn:
        print(conn.name)

What you see: AttributeError: __enter__ — a class with only __aenter__/__aexit__ has no __enter__/__exit__, so plain with fails immediately.

Why: with and async with call different protocol methods entirely — with looks for __enter__/__exit__, async with looks for __aenter__/__aexit__. A class defining only the async pair does not satisfy the sync protocol, so plain with cannot find the methods it needs.

Remember: async with awaits __aenter__() before the block and __aexit__() after — use it when setup/teardown itself needs to await something.

See also: with statement · contextlib contextmanager · context manager dunders · async context managers

Advertisement

Why they matter

The one guarantee — cleanup runs no matter how the block ends — applied to files, transactions, locks, resources, and temporary state.

Why context managers matter

standardbeginner

Context managers guarantee cleanup for anything that must be released — files, database transactions, locks, network connections, temporary state — no matter how the block ends.

Think of it as

Every use case here is the same shape: something is opened or changed, and it absolutely must be undone afterward, even if the code in between crashes. A context manager is the one pattern that guarantees the "undo" step every time.

python
with acquire_resource() as thing:
    use(thing)
# thing is released here, whether use(thing) succeeded or raised

What we're doing: Show the same transaction context manager committing on success and rolling back on failure, proving cleanup adapts to how the block actually ended.

transaction.pypython
from contextlib import contextmanager

@contextmanager
def transaction(db_name):
    print(f"BEGIN transaction on {db_name}")
    try:
        yield db_name
    except Exception:
        print(f"ROLLBACK {db_name}")
        raise
    else:
        print(f"COMMIT {db_name}")

with transaction("orders_db") as db:
    print(f"INSERT INTO {db}.orders ...")

try:
    with transaction("orders_db") as db:
        print(f"INSERT INTO {db}.orders ...")
        raise ValueError("constraint violation")
except ValueError as e:
    print(f"caught: {e}")
7–9
try/except/else: the except branch only runs if the block raised; the else branch only runs if it didn't.
8
On failure, ROLLBACK prints and the exception is re-raised — the transaction is not silently swallowed.
12
On success, COMMIT prints instead — the same context manager adapts its cleanup to how the block ended.
Output
BEGIN transaction on orders_db
INSERT INTO orders_db.orders ...
COMMIT orders_db
BEGIN transaction on orders_db
INSERT INTO orders_db.orders ...
ROLLBACK orders_db
caught: constraint violation

Why this works: The first with block completes normally, so the else branch runs and prints COMMIT. The second raises a ValueError, so the except branch runs instead, printing ROLLBACK before re-raising — the exception still reaches the outer try/except. One context manager correctly handles both outcomes, which is the entire value of the pattern: the caller writing with transaction(...) never has to remember to commit or roll back by hand.

Managing a resource without a context manager, then forgetting cleanup on one code path

Wrong

python
import threading
lock = threading.Lock()

lock.acquire()
process_shared_data()   # if this raises, lock.release() below never runs
lock.release()

Better

python
import threading
lock = threading.Lock()

with lock:
    process_shared_data()   # lock.release() runs no matter what

What you see: Every other thread waiting on lock.acquire() blocks forever — a silent deadlock with no error message, often noticed only as the whole program hanging.

Why: A manually acquired lock only releases if every code path after acquire() reaches release() — one missed branch, one early return, or one uncaught exception leaves it held forever. threading.Lock is itself a context manager for exactly this reason: with lock: guarantees release() runs before the block is left, on any exit path.

The same guarantee, applied to six kinds of cleanup

The same guarantee, applied to six kinds of cleanup
Use caseWhat must always happen
FilesThe file handle is closed, even if reading/writing raises
Database transactionsCOMMIT on success, ROLLBACK on failure — never left half-applied
LocksThe lock is released, so other code is not permanently blocked
Resources (sockets, connections)close()/disconnect() runs, even if the code using it errors
Temporary state (env vars, config)The original value is restored after the block, pass or fail
Cleanup (general)Any "undo" step runs unconditionally, without a repeated try/finally

Together

python
from contextlib import contextmanager

@contextmanager
def transaction(db_name):
    print(f"BEGIN transaction on {db_name}")
    try:
        yield db_name
    except Exception:
        print(f"ROLLBACK {db_name}")
        raise
    else:
        print(f"COMMIT {db_name}")

with transaction("orders_db") as db:
    print(f"INSERT INTO {db}.orders ...")

Remember: Context managers guarantee cleanup — closing files, committing or rolling back transactions, releasing locks, restoring state — no matter how the block ends.

See also: with statement · contextlib contextmanager · context manager dunders

Advertisement