skip to content

How does contextlib.nullcontext keep an optional lock from forcing two copies of a with block?

level: middleimportance: should knowfreq 30%

answer

  1. Two copies of a block is the smell
  2. Make the manager a value, not a branch
  3. A manager that does nothing on both ends
  4. __enter__ returns its argument, default None
  5. Pass an open stream it must not close

basics

~20 s

contextlib.nullcontext() is a context manager that does nothing: its enter returns its argument (None by default) and its exit ignores everything. Choose it or the real manager into one variable, then write the with block once.

solid answer

~50 s

The problem is that `with` needs *something*, so a conditional resource tempts you into `if incremental: with lock: BODY else: BODY` — two copies of the body that drift apart. `contextlib.nullcontext()` gives you a manager that is a no-op on both ends, so the choice moves into an expression: `guard = index_lock if incremental else nullcontext()` and then a single `with guard:` around one copy of the body. If the block uses an `as` binding, pass the value you want handed back: `nullcontext(sys.stdout)` makes `with manager as out:` bind the already-open stream in the branch that has no manager to create — and, crucially, `__exit__` does nothing, so that stream is not closed. It never suppresses exceptions, it is reusable and reentrant since it holds no state, and since Python 3.10 it also supports `async with`.

code

python · 17 lines
python
import threading
from contextlib import nullcontext

index_lock = threading.Lock()
written = []


def rebuild(batch, *, incremental):
    guard = index_lock if incremental else nullcontext()
    with guard:
        for doc in batch:
            written.append(doc)


rebuild(["a", "b"], incremental=True)
rebuild(["c"], incremental=False)
print(written)

go deeper

for a junior

Know that it is a context manager that does nothing and that with nullcontext() as x binds None unless you pass a value. Recognising it in code you read is enough here.

for a middle

Be able to refactor a duplicated if/else block into a single body with the manager chosen as a value, and explain why passing an open stream to it is safe — its exit does not close anything.

for a senior

Show where you have used it to make instrumentation or locking optional without forking the code path, and explain the ownership rule: a manager should only release what it acquired.

for a principal

Argue the broader principle — collapse branches over statements into branches over values so behaviour has one home — and weigh it against the readability cost when the two modes genuinely diverge.

### The shape of the problem A search-index rebuilder runs in two modes. A full rebuild owns the index exclusively and needs no lock; an incremental rebuild shares it with live writers and must hold one. The naive expression of that is a branch: ```python if incremental: with index_lock: for doc in batch: write(doc) mark_indexed(doc) else: for doc in batch: write(doc) mark_indexed(doc) ``` The body is now duplicated, and duplicated bodies rot at different rates. The failure this produces is dull and expensive: a fix lands in one copy, the other keeps its old behaviour, and documents get written twice on the path nobody exercised in staging — a duplicated side effect that surfaces late in a three-week release train, because the mode that broke is the one that only runs in production. ### What `nullcontext` is `contextlib.nullcontext` is a manager with nothing in it. `__enter__` returns the value it was constructed with, defaulting to `None`; `__exit__` does nothing at all and returns `None`, so no exception is ever suppressed. It exists purely so that "no manager" can be spelled as an object, which turns a control-flow branch into a value choice: ```python guard = index_lock if incremental else nullcontext() with guard: for doc in batch: write(doc) mark_indexed(doc) ``` One body, one place to fix. The interview point is not the class — it is recognising that a branch over *statements* can be collapsed into a branch over *values* whenever the two arms differ only in a manager. ### The `as` binding, and why the argument matters When the block binds a name, the placeholder branch usually still has a perfectly good value to bind — an already-open stream, a shared connection, a default object someone else owns. That is what the constructor argument is for: ```python manager = open(path, "w") if path else nullcontext(sys.stdout) with manager as out: ... ``` Two properties make this correct rather than merely convenient. `__enter__` hands back exactly the object passed in, so `out` is the real stream. And `__exit__` does nothing — so `sys.stdout`, which the function does not own, is left open, while the `open()` branch is closed normally by the file object's own `__exit__`. Getting the ownership asymmetry right is the whole reason not to hand-roll a "do nothing" class that calls `close()` "just in case". ### Other places it earns its keep - **Optional manager parameters.** A function that accepts a context manager can default the parameter to `None` and coerce with `cm = cm or nullcontext()`, so the body never has to test for absence. - **Turning a feature off.** A profiler, tracer or timing manager can be swapped for `nullcontext()` behind a flag, leaving the instrumented code identical in both configurations. - **Tests.** A test that sometimes expects a raise and sometimes does not can select between an assertion manager and `nullcontext()` in a parameterised case, keeping one body. ### Properties worth knowing - **It never suppresses.** `__exit__` returns `None`, so exceptions propagate exactly as if the `with` were not there. Anyone who thinks a "null" manager silently absorbs errors has the mental model backwards. - **It is reusable and reentrant.** It holds no per-use state, so one module-level `nullcontext()` instance can be shared, nested and re-entered safely. Most managers — anything generator-based, in particular — cannot be re-entered. - **It costs essentially nothing.** Two trivial method calls per block. The alternative of duplicating the body costs far more in maintenance. - **It is async-aware since Python 3.10.** It implements `__aenter__`/`__aexit__` as well, so the same placeholder works under `async with`. On 3.7–3.9 it was synchronous only. ### The alternatives, and why they lose The first alternative is the duplicated block above. The second is manual bracketing: `if incremental: index_lock.acquire()` … `finally: if incremental: index_lock.release()`, which reintroduces exactly the acquire/release bookkeeping `with` exists to remove, and needs the condition repeated in two places that can disagree. The third is a hand-written `class _Nothing:` in a utilities module — which is `nullcontext`, only untested, undocumented and lacking the async half. Reaching for the standard library is the answer the question is really testing. ### What to say in an interview "When a resource is conditional, I keep one body and make the manager a value: `guard = real if condition else contextlib.nullcontext()`. It returns its argument from `__enter__` — so I can pass an already-open stream when I need an `as` binding — and its `__exit__` does nothing, which means it neither closes what it did not open nor swallows exceptions."

  • What does `with contextlib.nullcontext() as x:` bind to x when you pass no argument?
    `None`, since that is the default for the value `__enter__` returns. That is fine when the real branch produces an object used only for its side effect, such as a lock. Pass an argument when the placeholder branch still needs a usable value — an already-open stream, a shared connection — so both branches bind something the body can use.
  • Does contextlib.nullcontext work with async with?
    Yes. Since Python 3.10 it implements the asynchronous protocol as well, so one instance stands in for an asynchronous manager under `async with`. On 3.7 through 3.9 it was synchronous only, and an async placeholder had to be written by hand.
  • Is it safe to share one nullcontext instance across calls, or even nest it?
    Yes. It stores only the value to return and keeps no per-use state, so a single instance is reusable and reentrant — sharing a module-level one is safe. Most managers are not: a generator-based manager is consumed by its first use and raises if you enter it again.
  • How would you let a caller pass an optional context manager into your function?
    Give the parameter a default of `None` and coerce at the top: `cm = cm or contextlib.nullcontext()`. The body then has one code path and no `is None` checks. Because nullcontext is stateless you can also default the parameter to a shared instance without the usual mutable-default hazard.

saying these in an interview costs you the question

  • Duplicates the whole block under an if/else
  • Uses try/finally with a None check instead
  • Thinks nullcontext suppresses exceptions in the block
  • Expects nullcontext to close the object passed to it
  • Hand-rolls a do-nothing manager class instead
  • Believes a fresh instance is required for every block

context