skip to content

How do you make a CI run fail on DeprecationWarning without breaking on third-party ones?

level: middleimportance: should knowfreq 45%

answer

  1. One filter action does the whole job
  2. Global is a trap, scope it
  3. Order matters: last option checked first
  4. action:message:category:module:lineno
  5. The module field means the attributed caller

basics

~20 s

Promote the category to an error, but scope it. Run the suite with -W ignore::DeprecationWarning -W error::DeprecationWarning:yourpackage, or set PYTHONWARNINGS to the same spec: the later filter is checked first, so only your own code is fatal.

solid answer

~40 s

Making warnings fatal is just a filter whose action is `error`, so the warnings machinery raises the instance instead of printing it. A global `-W error::DeprecationWarning` is a trap — the first failure is usually an import-time deprecation inside a dependency, and a dependency upgrade can turn the suite red with no change of yours. Scope it instead: `python -W ignore::DeprecationWarning -W error::DeprecationWarning:myapp -m unittest discover`, remembering that each `-W` is inserted at the head of the list, so the last one given wins. `PYTHONWARNINGS` takes the same spec and is inherited by child processes, which `-W` is not. In code the same shape is `warnings.simplefilter("ignore", ...)` followed by a narrower `warnings.filterwarnings("error", ...)`.

code

python · 14 lines
python
import warnings

warnings.simplefilter("ignore", DeprecationWarning)
warnings.filterwarnings("error", category=DeprecationWarning, module=r"__main__")


def legacy():
    warnings.warn("legacy() goes away in 2.0", DeprecationWarning, stacklevel=2)


try:
    legacy()
except DeprecationWarning as exc:
    print("this would fail the build:", exc)

go deeper

for a junior

Know that a warning can be turned into a real exception with the -W flag or the PYTHONWARNINGS environment variable, and that CI is the right place to do it.

for a middle

Explain the filter spec grammar, that later -W options are checked first because each is inserted at the head of the list, and why a broad ignore plus a narrow error is the standard pairing.

for a senior

Talk about keeping the signal stable over time: scoping by first-party module, pinning unavoidable third-party warnings by message with a ticket, and keeping fatal filters out of production while still logging warnings there.

for a principal

Set the standard across teams: which categories are fatal in which pipeline stage, how exceptions are reviewed and expire, and how deprecation counts feed the decision on when to schedule an interpreter or dependency upgrade.

### The goal, stated precisely Making warnings fatal means changing one filter action from `ignore` (or `default`) to `error`. When a matching filter says `error`, the warnings machinery raises the warning instance instead of printing it, so the call site blows up with a traceback and the process exits non-zero. That is the entire mechanism behind "fail the build on deprecations" — there is no separate mode, only the filter list. ### The three ways to say it * `python -W error::DeprecationWarning -m unittest discover` * `PYTHONWARNINGS=error::DeprecationWarning python -m unittest discover` * `warnings.simplefilter("error", DeprecationWarning)` in code, or the finer-grained `warnings.filterwarnings("error", category=DeprecationWarning, module=r"myapp(\.|$)")` The `-W` and `PYTHONWARNINGS` spec grammar is identical: `action:message:category:module:lineno`, with empty fields meaning "match anything", so `error::DeprecationWarning` means "error, any message, this category, any module, any line". Two operational differences are worth knowing. `PYTHONWARNINGS` is an environment variable, so it is inherited by every child process the run spawns — a subprocess, a worker process — whereas `-W` applies only to the interpreter you typed it on. And in the `-W`/`PYTHONWARNINGS` form the `module` field is regex-escaped before use, so it is a literal prefix match; only `warnings.filterwarnings(module=...)` accepts a real regular expression. ### Precedence: the last `-W` wins Each `-W` option is inserted at the **head** of the filter list as it is processed, and matching stops at the first hit. So the options are effectively evaluated right-to-left: write the broad rule first and the narrow override last. ``` python -W ignore::DeprecationWarning -W error::DeprecationWarning:myapp -m unittest discover ``` That reads as: fail on deprecations attributed to `myapp` (and `myapp.anything`, since the match is anchored at the start), ignore everybody else's. The same shape in code is a `simplefilter("ignore", DeprecationWarning)` followed by a narrower `filterwarnings("error", ...)`, because `filterwarnings` also inserts at the head unless you pass `append=True`. ### Why the naive `-W error` fails immediately Turn `error` on globally and the first thing that breaks is usually not your code. Import-time deprecations inside dependencies become `ImportError`-shaped crashes, and any library that warns about its own future changes takes the suite down. Worse, the failure is not stable: a dependency upgrade can add a new deprecation and turn a green suite red with no change of yours. That is why the useful configuration is *scoped*, not global. ### The attribution trap Scoping by module is where candidates get burned, and it is the interesting half of this question. The `module` field is matched against the module the warning is **attributed to**, which `stacklevel` chooses — normally the *caller*. A well-behaved library uses `stacklevel=2` precisely so that its deprecation points at your line of code, which means: * Deprecations triggered by your calls into a dependency are attributed to **your** module, so they are caught by a `myapp`-scoped `error` filter. That is what you want. * Deprecations a dependency raises internally, or at import time, are attributed to that dependency, so a `myapp`-scoped filter ignores them. Also what you want. * A library that forgets `stacklevel` will be attributed to itself, and its warnings will slip through your scoped filter even though it is *your* call that provokes them. So a module-scoped `error` filter is a good approximation of "our own technical debt", not a guarantee. When one specific third-party deprecation is unfixable and keeps failing the run, pin it by message regex rather than widening the category: `warnings.filterwarnings("ignore", message=r"the exact text.*", category=DeprecationWarning)` — a narrow, dated, reviewable exception, ideally with a ticket reference in a comment. ### Where to put it, and what else to do with it Put the policy where the test command lives so it applies to every run, and keep the same setting out of production: `error` in a live service converts a cosmetic deprecation into an outage. A common split is `error::DeprecationWarning` scoped to first-party code in CI, `default::DeprecationWarning` under `-X dev` locally, and stock filters in production with the warnings routed into the log stream by `logging.captureWarnings(True)` so they are still countable. Two refinements worth naming. First, tests that deliberately exercise a deprecated path should assert on the warning rather than fight the filter — `warnings.catch_warnings()` gives you a scoped filter list that is restored on exit. Second, `error` on `DeprecationWarning` alone is the safe start; `FutureWarning` and `PendingDeprecationWarning` are separate categories and are not covered by it, and `-W error` with no category makes *every* warning fatal, which almost nobody actually wants.

  • A dependency's deprecation keeps failing your scoped run and you cannot fix it yet. What is the least damaging escape hatch?
    Pin that one warning by its message rather than widening the category: `warnings.filterwarnings("ignore", message=r"exact text of the warning.*", category=DeprecationWarning)`. It is narrow, greppable and obviously temporary, and it leaves every other deprecation fatal. Widening to `ignore::DeprecationWarning` for the whole run, or dropping the `error` filter entirely, buys the same green build while silencing the signal you installed the filter to get.
  • Why is a module-scoped error filter only an approximation of "our own deprecated calls"?
    Because the module field matches the module the warning is *attributed* to, which `stacklevel` chooses. A library that correctly passes `stacklevel=2` blames your calling module, so your scoped filter catches it — good. A library that omits `stacklevel` blames itself, so the identical mistake in your code slips through the filter. You get most of the signal, not all of it, and you should not claim otherwise.
  • Should the same error filter be enabled in production?
    No. In CI a fatal warning is a useful stop sign; in a live service it converts a cosmetic deprecation into an outage, and one that appears the moment a dependency is upgraded. Production should keep the stock filters and route warnings into the log stream with `logging.captureWarnings(True)`, so they are counted and alertable without being able to kill a request.

saying these in an interview costs you the question

  • Turning -W error on globally and calling it done
  • Thinking the first -W option wins over later ones
  • Believing -W propagates to child processes
  • Assuming the module field names where the warning was raised
  • Enabling fatal warnings in production as well as CI
  • Silencing a whole category to fix one noisy warning

context