Why does re-entering the same context-manager object nested break it, and what makes one reentrant?
answer
- One slot cannot hold two entries
- Reusable and reentrant are different questions
- Where does the saved value live?
- Push on enter, pop on exit
- Fresh object per use dodges the whole problem
basics
~20 sA 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 sNothing 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 linesclass 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
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.
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.
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.
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