AlgoMaster Logo

with Statement

High Priority22 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

The with statement is a cleaner alternative to try/finally for closing files and releasing locks. This chapter goes under the hood: how Python actually desugars a with statement, the exact order managers are entered and exited, what happens when __exit__ itself blows up, and the difference between reusable, reentrant, and one-shot managers. By the end, the surprises you might hit (a name still bound after the block, an exception that gets replaced, a half-set-up resource that still cleans up) will all make sense.

The Exact Desugaring of with

The rough equivalence between with and try/finally is well known. The real desugaring (defined in PEP 343 and the language reference) is a little more careful. Here's the form for with EXPR as VAR::

Two details from that desugaring are worth pulling out, because they trip up real code.

First, __enter__ and __exit__ are looked up on the class, not on the instance. The line that calls __enter__ is type(manager).__enter__(manager), not manager.__enter__(). This is the same rule Python uses for all special methods (__len__, __iter__, __eq__, and so on). If you assign a different __enter__ function to an instance, with ignores it.

The instance-level __enter__ is never called. with resolved type(cart).__enter__ and used the class version. If you ever wonder why monkey-patching __enter__ on a single instance doesn't take effect, this is the reason.

Second, the as VAR assignment happens after __enter__ returns. If __enter__ raises, VAR is never bound. The block body never runs. And, importantly, __exit__ is also not called for that manager, because the manager never entered in the first place. The protocol is symmetric: __exit__ only matches a successful __enter__.

__exit__ didn't print. The body didn't print. x was never bound. The exception from __enter__ propagated directly to the caller, with no cleanup attempt. That's correct: there was nothing to clean up.

Entry and Exit Order for Multiple Managers

The comma form with open(a) as fa, open(b) as fb: is equivalent to a nested pair of with statements, and the nesting order matters.

is desugared into:

Two consequences fall out of that:

  1. Entry order is left to right. orders is opened first. Only if that succeeds does customers get opened.
  2. Exit order is right to left. When the block ends, customers.__exit__ runs first, then orders.__exit__.

This mirrors how stacks work everywhere else in Python: last in, first out. The reason it's "right to left" rather than "right to left" being a quirk is that each manager is responsible for cleaning up after itself, and the inner manager (which may depend on the outer one being alive) has to release before the outer does.

You can see the LIFO behavior plainly: enter cart, order, invoice, then exit in reverse.

The diagram below shows the same lifecycle as a flow:

The asymmetry (top down for entry, bottom up for exit) is the same shape you see in function calls, try/finally chains, and any other LIFO setup-teardown pattern in the language. It's not specific to with; it just shows up clearly here because the protocol makes it explicit.

The Parenthesized Form (Python 3.10+)

Comma chains get awkward past two or three managers. Python 3.10 added a parenthesized form that lets you break the list across multiple lines, with optional trailing commas:

This is purely a parsing change. The semantics are identical to a single comma-separated list. The parentheses aren't a tuple; they're part of the with syntax. You can use them whenever a single line gets too long to read comfortably, which in practice is around three managers.

Before 3.10, the workaround was either a single long line or contextlib.ExitStack. After 3.10, parenthesized with is the idiomatic choice for static lists of managers.

A small e-commerce example: exporting an order to three formats in one block.

All three files are opened in order, the body writes to each, and all three are closed in reverse order before the program continues. If the disk filled up halfway through and the third open raised, the first two would still be closed cleanly.

Partial Setup: When a Later __enter__ Raises

If a later manager's __enter__ fails, earlier ones get cleaned up. Here's the full picture, because the rule is subtle.

When with A() as a, B() as b, C() as c: runs:

  1. A.__enter__ is called. Suppose it succeeds.
  2. B.__enter__ is called. Suppose it succeeds.
  3. C.__enter__ is called. Suppose it raises RuntimeError.
  4. Python is now stuck between "C didn't enter" and "A and B did". It treats this as a normal exception in flight after the inner with block was opened, so it unwinds:
  • B.__exit__(RuntimeError, ..., ...) runs.
  • Then A.__exit__(RuntimeError, ..., ...) runs.
  • The RuntimeError then propagates out of the with statement.
  1. C.__exit__ is never called, because C never entered. The protocol guarantees __exit__ matches a successful __enter__, not an attempted one.

Here's a worked trace using small custom classes:

Notice the order: A enters, B enters, C tries to enter and raises. B's __exit__ runs first (it was entered most recently), then A's. Both __exit__ calls see the RuntimeError info, because that's the exception currently in flight. C's __exit__ is absent, because C never returned from __enter__.

This is the exact behavior PEP 343 specifies, and it's also what contextlib.ExitStack produces. The takeaway: even with a partial entry, every resource that did enter gets a paired exit. You can't leak the first two files because the third one failed to open.

What __enter__ Returns

The value bound by as VAR is whatever __enter__ returns. There's no rule that says it has to be self, and different built-ins make different choices.

ManagerWhat __enter__ returnsWhy
open(path)The file object itself (self)The file is what you want to read and write
threading.Lock()The lock itselfYou typically don't reference it inside the block
decimal.localcontext()A copy of the current contextMutating the copy doesn't disturb other code outside the block
tempfile.TemporaryDirectory()The directory path (a string)The path is what you actually pass to open, os.listdir, etc.
contextlib.suppress(...)self (irrelevant)You don't use the binding; you don't write as name either

The two interesting cases are "returns self" and "returns a copy".

When __enter__ returns self, you're working with the same object that owns the cleanup. Files are the classic example:

log and the manager are the same object. If you wanted to call other methods on the file (log.flush(), log.fileno()), they're right there.

When __enter__ returns a separate object, the as binding gives you that separate thing, while the manager stays in the background. decimal.localcontext is the canonical example:

ctx is a copy of the current decimal context. Setting ctx.prec = 6 changes that copy, which is the active context inside the block. On exit, localcontext restores the original context, so the precision goes back to 28. The trick is that mutating ctx doesn't accidentally bleed into other parts of the program; the copy is scoped to the with block.

The shape "enter clones state, exit restores it" shows up in a lot of well-designed managers: numpy.errstate, warnings.catch_warnings, and Python's own unittest.mock.patch all work this way. Recognizing the pattern makes their behavior predictable.

For contextlib.suppress, the return from __enter__ is self, but you almost never write as name for it, because there's nothing useful to do with the suppressor inside the block. You'd just write with suppress(KeyError): and the body alone.

When __exit__ Itself Raises

Most of the time, __exit__ returns normally. When the body of a with block raised, __exit__ does its cleanup, returns False/None, and the original exception keeps propagating. Clean.

But what if __exit__ itself raises? Imagine a database manager whose commit() on the way out fails because the connection died, or a file whose close() raises on a flushed write error. Python has a specific rule for this case: the new exception from `__exit__` replaces the original exception, and the original is attached to the new one as `__context__`.

This is the same chaining mechanism raise from uses. The exception that propagates out of the with statement is the one __exit__ raised, but the one that was in flight when __exit__ was called is preserved on the chain so you don't lose the diagnostic.

Here's a concrete demo:

Walk through this. The body raises ValueError. Python catches it (just long enough to call cleanup), invokes FlakyManager.__exit__(ValueError, ..., ...), which prints the type and then raises RuntimeError("cleanup failed"). Python sees the new exception, replaces the in-flight ValueError with it, and sets __context__ on the RuntimeError to the original ValueError. The except RuntimeError clause catches the new exception; the original is still reachable through __context__.

If you let the exception propagate all the way to the interpreter, the traceback prints both, separated by:

That's the "implicit context" wording you see in real tracebacks when one exception fires while handling another. The with statement is one of the most common sources of it.

The diagram below shows the replacement flow:

There's one more wrinkle worth flagging. If __exit__ raises while the body did not raise (so the block was about to exit normally), the new exception simply propagates out, with no __context__ to attach. That's the same as any other normal-path raise.

The body finished without raising. __exit__ raised IOError on the way out, and that's the exception the caller sees. There's no original to chain to, so __context__ is None.

This behavior is why you should write __exit__ methods carefully. If cleanup itself can fail, decide in advance whether the cleanup error or the body error is more important to the caller. Most well-designed managers try to do all their cleanup work and then re-raise the original exception (if there was one), rather than masking it.

Reusable, Reentrant, and One-Shot Managers

When you write with cart_lock:, can you use the same cart_lock object again in a different with block later? What if you use it again inside the first block, before the first one has exited? The answer depends on which of three categories the manager falls into.

One-shot: the manager can be entered exactly once. After exit, calling __enter__ again raises. Generators wrapped with @contextmanager are one-shot by default.

Reusable: the manager can be entered multiple times in sequence, but not while it's already inside an active with block of its own. File objects are this way: you can open() a file, use it, close it, then open it again, but you can't enter the same file object while it's already inside an enclosing with. Actually file objects allow reentry on the same file object, but reading and writing through it after close fails. The exact rule varies by class.

Reentrant: the manager can be entered again while a prior entry is still active. threading.RLock (reentrant lock) is the canonical example: a thread that already holds the lock can acquire it again, and the lock keeps a count of how many times the same thread has entered. The lock is only fully released when the count drops back to zero.

A short demo of each:

The first with consumed the generator. Trying to reuse the same cm raises, because the generator is exhausted.

The point here is to recognize the distinction when reading docs: "the lock is reentrant" or "this manager is one-shot" tells you something specific about whether you can use it twice.

A practical implication for e-commerce code: if you're using a single connection object as a context manager to wrap a transaction, check whether the driver supports reentry. Some drivers do; some don't. The wrong assumption can lead to surprising deadlocks or "transaction already in progress" errors in production.

The as Name Outlives the Block

A with block does not introduce a new scope. The name bound by as is a regular local variable in the surrounding function (or module). It's still accessible after the block ends, with whatever value it held at exit.

log is still defined after the with block. The file object is closed (the __exit__ flipped closed to True), but the variable hasn't gone anywhere. You could call log.closed or log.name and get sensible answers; just don't try to read or write through it, because the file is shut.

This catches some readers off guard, especially anyone who's used to languages where block constructs (for-loop variables, if-bound names, scoped declarations) limit visibility. In Python, the only constructs that introduce a new scope are functions, classes, modules, and generator expressions/comprehensions. Statements like if, for, while, try, and with do not. A name assigned inside any of those is visible after the construct.

f and last_line are both reachable on the return line. f happens to be a closed file (touching it for I/O would raise), but the binding is alive. Python's scoping rules don't care that you "left the with block"; the function is one scope, top to bottom.

If you want a name scoped to the block, you have to delete it explicitly:

In practice, you rarely care. The closed file object isn't going to do anything dangerous, and the name typically goes out of scope when the function returns anyway. Just know the rule so it doesn't bite you in a code review: "the variable from a with block is still in scope" is a Python answer that surprises people.

Putting It All Together: A Worked E-Commerce Example

To pull the pieces from this chapter together, here's a small program that exports a cart to two files (CSV and JSON), guarded by a lock, and demonstrates how the layers nest and unwind cleanly.

A few things to notice:

  • Three managers in one statement: the lock plus the two files. The parenthesized form holds the two file managers; the lock sits outside it. (You could nest the lock inside the parens too. Both are valid syntax.)
  • The lock enters first, then the CSV file, then the JSON file. If the JSON open had failed (disk full, permission denied), the CSV would still be closed and the lock would still be released, in that order, before the exception propagated.
  • If any line in the body raised, all three managers would still exit. The lock release is the last thing to happen, so other threads aren't stuck behind a crashed exporter.
  • The names csv_file and json_file are still bound after the block; the files they point to are closed. You wouldn't read from them, but you could log csv_file.name if you wanted to record what was written where.

This is the shape that most real with usage takes: a couple of resources, opened together, guaranteed to release together, regardless of what the body does. The cleanup story is the same whether the body finishes normally, raises, or returns from the enclosing function partway through.

Quiz

with Statement Quiz

10 quizzes