Skip to content
← All articles

suppress, closing, nullcontext, chdir

nullcontext eliminates the use-it-if-given-otherwise-open-one duplication. suppress abandons the rest of the block, which is not the same as except: pass. chdir is process-global.

Four small tools from contextlib. Each replaces a specific piece of boilerplate, and three of them have a sharp edge.

nullcontext: the optional-resource pattern

The duplication it removes:

if stream is not None:
    result = process(stream)          # body, written once
else:
    with open(path) as f:
        result = process(f)           # body, written again

Two copies of the body, which drift. With nullcontext:

scope = nullcontext(stream) if stream is not None else open(path)
with scope as active:
    result = process(active)

nullcontext(x) is a context manager that yields x and does nothing on exit. The caller-supplied stream is therefore not closed by the callee, and the internally-opened one is — which is the correct ownership rule and the reason this is more than a cosmetic tidy-up. Closing a handle you did not open is one of the classic ways to break a caller.

The generic is what makes it type-check: nullcontext() reveals nullcontext[None] and nullcontext(5) reveals nullcontext[int], so the conditional expression above unifies to AbstractContextManager[Stream] and active is typed correctly in the body.

closing: adapting an object that only has close()

with closing(urlopen(url)) as page:
    ...

Calls close() on exit, nothing on entry. For any object with a close() and no __enter__ — older library objects, urlopen on some versions, a hand- written handle. If the object is a context manager already, closing is redundant and slightly misleading.

suppress: not the same as except: pass

with suppress(FileNotFoundError):
    return load()
return default

The important, easily-missed property: when the exception fires, the rest of the block is abandoned. It is not “ignore the error and carry on with the next statement” — it is “jump to the end of the with“. So this is wrong:

with suppress(KeyError):
    a = d["a"]
    b = d["b"]        # skipped entirely if "a" was missing

Use one suppress per statement whose failure you are willing to ignore, or a try/except if you need the following statements to run.

And the exception tuple must be narrow. suppress(Exception) around a file read swallows PermissionError, IsADirectoryError, OSError from a full disk, and every bug in the code you called — turning a misconfigured deployment into a silently empty config. Name the exceptions you actually expect.

Since 3.12, suppress also removes matching exceptions from a BaseExceptionGroup and re-raises the group with the rest, which makes it work sensibly inside a TaskGroup.

💡with suppress(FileNotFoundError): return load() followed by click to reveal

return default — how does the type checker know the second return is reachable, given the first one is unconditional? Because suppress.__exit__ is typed as returning bool, not Literal[False]. mypy treats a with whose __exit__ may return truthy as able to swallow an exception, which means control can reach the statement after the block even when the body ends in return.

This matters more than it sounds, because --strict includes --warn-unreachable in many setups: if __exit__ were typed -> Literal[False] (as it is for open()), mypy would mark return default unreachable and error on it. The stub author’s choice of return type is what makes the idiom expressible.

It also tells you how to type your own suppressing context manager: annotate __exit__ as -> bool when it may suppress, and -> None when it never does. Getting that wrong produces spurious unreachable-code errors in every caller.

chdir: correct and dangerous

contextlib.chdir(path) (3.11) changes the working directory and restores it on exit. It is reentrant — nesting works, each level restores the previous directory.

It is also not thread-safe, and cannot be: the working directory is process-global state. A chdir in one thread silently changes what every relative path in every other thread resolves to. The same applies to the asyncio event loop — a chdir around an await moves the directory for whatever coroutine runs next.

Use it in single-threaded scripts and tests. In a server, pass absolute paths instead; pathlib makes that painless.

💡What is the type of nullcontext(stream) if stream is not None else closing(open(path)), click to reveal

and why does mypy need help with it? Each branch is a different class — nullcontext[TextIO] and closing[TextIO] — and mypy joins the branches of a conditional expression to their nearest common supertype. Here that is AbstractContextManager[TextIO], which is what you want, but the inference can land somewhere less useful when the branches are more distant.

The robust move is to annotate the variable:

scope: AbstractContextManager[TextIO] = (
    nullcontext(stream) if stream is not None else closing(open(path))
)

Now the checker verifies both branches against the declared type rather than inventing one, and with scope as active: gives active: TextIO regardless.

The general habit: when a conditional expression produces values of different classes that share only an interface, declare the interface. It converts a quiet inference into a checked contract.