skip to content

Why does re-entering the same context-manager object nested break it, and what makes one reentrant?

level: seniorimportance: nice to knowfreq 18%

answer

  1. One slot cannot hold two entries
  2. Reusable and reentrant are different questions
  3. Where does the saved value live?
  4. Push on enter, pop on exit
  5. Fresh object per use dodges the whole problem

basics

~20 s

A manager that keeps per-entry state in a single instance attribute clobbers it on the second entry, so the first __exit__ restores the wrong value. Reentrancy requires per-entry state — a stack of saved values, or a fresh token returned by each __enter__.

solid answer

~50 s

Nothing in the `with` protocol says a manager object may only be entered once, so a single instance can legally be entered again while it is already active — directly, or via recursion through code that re-enters it. The problem is state: a manager that does `self._saved = self.value` in `__enter__` has exactly one slot for it, so the inner entry overwrites the outer's saved value and the outer `__exit__` restores the wrong one. The fix is to make entry state per-entry rather than per-object: push onto a list in `__enter__` and pop in `__exit__`, or have `__enter__` return a fresh token object that carries the state and let the manager be a factory. Distinguish this from **reusability** — being entered again *after* it has exited — which is a separate property; a manager can be one, both or neither, and the class should document which.

code

python · 18 lines
python
class Depth:
    def __init__(self):
        self.level = 0

    def __enter__(self):
        self._saved = self.level
        self.level += 1
        return self

    def __exit__(self, exc_type, exc_value, tb):
        self.level = self._saved


d = Depth()
with d:
    with d:
        print("inner", d.level)
print("after", d.level)

go deeper

for a junior

Know that with obj: calls plain methods on obj, so entering the same object twice is allowed and the language will not warn you. Constructing a fresh manager for each block avoids the question entirely.

for a middle

Explain the mechanics: one attribute holding the saved value is overwritten by the inner entry, so the outer exit restores a stale value. Show the push/pop fix and be able to run it.

for a senior

Diagnose it in a real system, where the nesting is indirect through a shared module-level instance, and the symptom is silently wrong state rather than an exception. Name reentrancy, reusability and thread safety as three separate guarantees.

for a principal

Set the convention: managers are factory-constructed by default, shared instances are rare and must document their re-entry and concurrency guarantees, and any save/restore manager gets a nested test before it ships.

### Three properties people conflate For a context-manager object, three different questions have three different answers: * **Reusable** — can this object be entered again *after* a previous `with` block on it has finished? * **Reentrant** — can this object be entered again *while* it is still active, i.e. nested inside its own block, or re-entered by a function the block calls? * **Thread-safe** — can two threads be inside blocks on the same object at once? They are independent. Reentrancy implies reusability in practice, but not the reverse, and neither implies thread safety. The `with` protocol itself says nothing about any of them: `__enter__` and `__exit__` are just methods, and calling them again is not an error the language will catch for you. ### The failure mode, concretely The classic broken shape stores the previous state in one attribute: ```python class Depth: def __init__(self): self.level = 0 def __enter__(self): self._saved = self.level # one slot for all entries self.level += 1 return self def __exit__(self, exc_type, exc_value, tb): self.level = self._saved ``` Enter it once: `_saved = 0`, `level = 1`. Enter the *same object* again inside that block: `_saved` is overwritten with `1`, `level = 2`. The inner exit restores `level = 1` — correct so far. The outer exit then restores `level = _saved`, which is still `1`. The counter never returns to zero. Nothing raised, nothing logged; the object is simply wrong from then on. That is the shape of every save/restore manager: a temporarily swapped global, a redirected output stream, a pushed logging context, a set-and-restore configuration flag. Each one is broken under nesting if the saved value lives in a single attribute. ### Why it shows up in production and not in tests The nesting is rarely visible in one function. Consider a genome-annotation pipeline with a stage manager that opens a transaction and, on failure, performs a partial-failure rollback of the records that stage wrote. A unit test enters it once. In production a stage handler calls a shared helper that — quite reasonably — wraps its own work in the *same* manager instance pulled from module state. Now the manager is nested against itself, and the rollback boundary is wrong: the outer exit restores state the inner exit already restored, so a failure rolls back too little or too much. At a 1,200-request-per-minute peak that surfaces as a small, steady rate of half-written annotation batches with no error in the logs — the worst class of bug to diagnose, because every individual code path reads correctly. ### Making it reentrant: keep a stack The general fix is to make the per-entry state actually per-entry: ```python class Depth: def __init__(self): self.level = 0 self._stack = [] def __enter__(self): self._stack.append(self.level) self.level += 1 return self def __exit__(self, exc_type, exc_value, tb): self.level = self._stack.pop() ``` Entries push, exits pop, and because `with` guarantees a matching `__exit__` for every completed `__enter__`, the stack stays balanced even when the block raises. This is the same idea a reentrant lock uses — count the entries, only really release on the outermost exit. ### Making it reentrant: hand out a token The other fix removes shared mutable state from the manager entirely. `__enter__` returns a fresh object that owns the entry's state, and the manager holds nothing per-entry: ```python class Scope: def __init__(self, registry): self.registry = registry def __enter__(self): entry = {"records": []} self.registry.append(entry) return entry def __exit__(self, exc_type, exc_value, tb): entry = self.registry.pop() if exc_type is not None: entry["records"].clear() ``` Or, more simply still: make the thing you `with` a *fresh object each time* — `with make_scope() as s:` rather than `with SHARED_SCOPE as s:`. A manager that is constructed at the point of use cannot be nested against itself, which is why factory-style managers dominate in practice and why the bug is mostly seen with module-level singletons. ### Threads are the same bug, one level up A stack fixes nesting within one thread and does nothing for two threads sharing the object — both push and pop the same list, and the values interleave. If a manager must be shared across threads *and* nested, the per-entry state belongs in thread-local storage, or the object should not be shared at all. Stating that boundary explicitly is what separates a senior answer from a correct-but-narrow one. ### What to say in an interview Name the three properties, show the single-attribute failure, give the stack fix in four lines, and then say what you would actually do: prefer factory-constructed managers, and document reentrancy and reusability in the class docstring when the manager is a shared object. Silence about those properties in a shared manager is a design defect, not a documentation nit.

  • What is the difference between a reentrant and a reusable context manager?
    Reusable means the object can be entered again after a previous block on it has completed. Reentrant means it can be entered again while a block on it is still active — nested inside itself, often indirectly through a function the block calls. Reentrancy is the stronger property and implies reusability in practice; a manager may be reusable without being reentrant, which is exactly the case that surprises people.
  • Does making a manager reentrant with a stack also make it safe to share across threads?
    No. Two threads pushing and popping the same list interleave their entries, so a thread can pop a value another thread saved. Nesting and concurrency are separate problems: the stack fixes re-entry within one thread, while sharing across threads needs the per-entry state in thread-local storage, or the manager not shared at all — usually the simpler answer is a fresh manager object per thread.
  • How do you avoid the whole class of bug in a codebase?
    Prefer factory-style managers constructed at the point of use — `with make_scope() as s:` — since an object that exists only for one block cannot be nested against itself. Reserve shared, module-level manager instances for cases that genuinely need shared state, and document reentrancy, reusability and thread-safety on those classes explicitly.

It is a coat-check with a single hook: the second coat covers the first, and when you hand back a ticket you return whichever coat is on top — everyone leaves with the wrong garment and nobody notices until the weather turns.

saying these in an interview costs you the question

  • Assumes every context manager can be nested against itself
  • Uses reentrant and reusable as synonyms
  • Stores per-entry state in one instance attribute
  • Thinks the `with` statement detects double entry
  • Believes a stack of saved values also fixes thread sharing
  • Shares a module-level manager instance without documenting the properties

context