skip to content

How does warnings.catch_warnings isolate filter changes inside a test?

level: seniorimportance: should knowfreq 38%

answer

  1. Global state, borrowed and given back
  2. Only two things are saved
  3. A copy, not a clean slate
  4. Recording is not enough on its own
  5. Two threads will corrupt each other's snapshot

basics

~20 s

warnings.catch_warnings is a context manager that snapshots warnings.filters and the display hook on entry and restores both on exit, so filters you install inside the block cannot leak into later code. With record=True it also collects the warnings raised inside.

solid answer

~40 s

Entering `warnings.catch_warnings()` saves the current `warnings.filters` list and `warnings.showwarning`, and exiting puts both back, so any `simplefilter` or `filterwarnings` call inside the block is undone. Passing `record=True` additionally replaces the display hook with one that appends to a list the context manager yields; each recorded item carries `message`, `category`, `filename` and `lineno`. Two gotchas matter. First, recording alone does not defeat deduplication - you normally set an `always` filter inside the block, or since Python 3.11 pass `action="always"` directly to `catch_warnings`. Second, it manipulates process-global state, so it is **not thread-safe**: two threads entering it concurrently will restore each other's snapshots. `unittest.TestCase.assertWarns` wraps the same machinery for the common assertion.

code

python · 10 lines
python
import warnings

def refresh_model():
    warnings.warn("refresh_model() is deprecated", DeprecationWarning, stacklevel=2)

with warnings.catch_warnings(record=True, action="always") as caught:
    refresh_model()

print(len(caught), caught[0].category.__name__, str(caught[0].message))
print(len(warnings.filters))

go deeper

for a junior

Know that it is a context manager which puts the warning filters back the way it found them, and that record=True gives you a list of what was emitted inside the block.

for a middle

Explain that it snapshots the filter list and the display hook only, that the block inherits existing filters rather than starting clean, and why you set an always filter before recording.

for a senior

Show that you know it mutates process-global state with no locking, and that you would set policy at interpreter start rather than toggling filters at runtime in a concurrent process.

for a principal

Decide where warning policy lives for a whole codebase - startup configuration versus per-test manipulation - and accept the parallel-test constraint that global filter state imposes on the suite's design.

### What the context manager actually saves `warnings.filters` is a module-level list, and `warnings.showwarning` is a module-level function reference. Both are process-global: a filter installed anywhere affects every module in the interpreter. `warnings.catch_warnings()` exists because tests and libraries need to change that state temporarily. On `__enter__` it copies the filter list and stashes the current display hook; on `__exit__` it restores both, whatever happened in between, including on an exception. That is the whole isolation guarantee - and it covers only these two things, nothing else. Entering also bumps an internal version counter for the filters. That matters because of deduplication: the machinery caches, per module, which (message, category, line) combinations it has already shown, and the cache is invalidated when the filter configuration changes. So a warning that already fired earlier in the process can fire again inside the block - without that invalidation, a test would depend on whether an earlier test had already triggered the same line. ### Recording ```python with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") do_the_thing() assert caught and caught[0].category is DeprecationWarning ``` `record=True` installs a display hook that appends to a list instead of writing to standard error, and yields that list. Each entry is a `warnings.WarningMessage` with `message` (the warning *instance*, not the string), `category`, `filename` and `lineno`. Note the type: `caught[0].message` is an exception object, so comparing it to a string always fails; use `str(caught[0].message)` or match against the category. The `simplefilter("always")` line inside is not optional decoration. Without it the ambient filters still apply: a `DeprecationWarning` hits the default `ignore` entry and is never handed to the hook, so the list stays empty and the test passes for the wrong reason - or fails mysteriously. Since Python 3.11, `catch_warnings` accepts `action`, `category`, `lineno` and `append` keyword arguments and applies them for you, so `catch_warnings(record=True, action="always")` is the modern one-liner. ### Why it is not thread-safe Because the state is global and the save/restore is a plain snapshot, two threads that enter overlapping `catch_warnings` blocks will corrupt each other: the second to exit restores the snapshot it took, which already contained the first thread's modifications, or reverses them. There is no locking and no per-thread filter list on CPython 3.14. Consequences for real work: do not use it in library code that may run under a thread pool; do not run a test suite that relies on it with parallel in-process workers; and if you need one process to both filter warnings and serve concurrent work, set the policy once at startup with `-W` or `PYTHONWARNINGS` rather than toggling it at runtime. ### Restoring is not clearing A frequent misconception is that the block starts from a clean slate. It does not - it starts from a *copy of whatever is currently configured*, including the interpreter defaults and anything the process installed earlier. If a conftest-level or application-level `ignore` is in place, it is in place inside your block too until you override it. Equally, `warnings.resetwarnings()` inside the block wipes the copy, and the wipe is undone on exit, which is the only safe place to call it at all. ### The assertion helpers For the common case - *this call must emit this category* - the standard library already wraps the pattern: `unittest.TestCase.assertWarns(DeprecationWarning)` and `assertWarnsRegex` are context managers that install an `always` filter, capture, and fail the test if nothing matching was emitted. They also expose the captured warning through the context object, so you can assert on the message text afterwards. Prefer them over hand-rolled capture for assertions, and keep `catch_warnings` for the cases they do not cover: asserting that *no* warning was emitted, counting occurrences, or checking the attributed filename. ### Testing the negative The inverse assertion is worth calling out because it is easy to get wrong. To assert a code path is *silent*, the reliable form is to install an `error` filter for the category inside the block and let any emission raise, which produces a real failure with a traceback pointing at the offending call, rather than recording into a list and asserting it is empty - an assertion that also passes when a stray `ignore` filter swallowed the warning before the hook ever saw it.

  • Why does catch_warnings(record=True) sometimes yield an empty list even though the code under test warns?
    Because recording replaces the display hook but does not change the filters. The block inherits whatever is configured, so a `DeprecationWarning` still hits the default `ignore` entry and never reaches the hook. Set an `always` filter inside the block, or pass `action="always"` to `catch_warnings` on Python 3.11 and later. A previously-shown warning is not the cause: entering the block invalidates the already-shown caches.
  • Your test suite runs with in-process parallel workers and warning assertions fail intermittently. What is the likely cause?
    `warnings.filters` is process-global and `catch_warnings` restores a plain snapshot with no locking, so overlapping blocks in different threads clobber each other's saved state. Either run those tests in one thread, isolate them in separate processes, or set the warning policy once at interpreter start with `PYTHONWARNINGS` instead of toggling it per test.
  • How do you assert that a code path emits no warning at all?
    Install an `error` filter for the category inside a `catch_warnings` block and call the code: any emission raises and the test fails with a traceback pointing at the source. Recording and asserting the list is empty is weaker, because a filter that silently ignores the category also produces an empty list, so the test passes without proving anything.

saying these in an interview costs you the question

  • Thinks the block starts with an empty filter list
  • Records warnings without setting an always filter
  • Compares the recorded message to a string, not an instance
  • Assumes catch_warnings is safe across threads
  • Calls simplefilter in a test without any context manager
  • Believes catch_warnings restores state only on clean exit

context