skip to content

What does the stacklevel argument to warnings.warn() control?

level: middleimportance: should knowfreq 32%

answer

  1. It changes who gets the blame
  2. Nothing about control flow changes
  3. The library's line is not the useful one
  4. It also decides which filter matches
  5. Pass 2 to point at the caller

basics

~20 s

stacklevel picks which stack frame the warning is blamed on. The default 1 points at the warn() call itself; 2 points at that function's caller, which is what a deprecated API should report so the user sees their own line.

solid answer

~40 s

`warnings.warn(msg, category, stacklevel=n)` walks `n` frames up from the `warn()` call to decide the filename, line number and module recorded for the warning. With the default `stacklevel=1` a deprecation inside your library prints your own source line, which tells the user nothing they can act on. `stacklevel=2` blames the caller, so the message points at the line that used the deprecated API. The attributed module matters twice over: it is what the `module` field of a filter is matched against, and it is why a library-attributed `DeprecationWarning` stays hidden while a caller-attributed one is shown when the caller is `__main__`. Wrappers need a higher number; Python 3.12 added `skip_file_prefixes` as a more robust alternative for helper layers.

code

python · 10 lines
python
import warnings

def score_v1(payload):
    warnings.warn("score_v1() is deprecated; use score()", DeprecationWarning, stacklevel=2)
    return len(payload)

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

print("warn() is on line 4; blamed line is", caught[0].lineno)

go deeper

for a junior

Remember that the number chooses which source line is printed with the warning, and that a library deprecation normally passes 2 so the message names the caller.

for a middle

Explain that the attributed frame supplies the filename, line and module, and that the module is what filter rules match on - so the level affects visibility, not just wording.

for a senior

Demonstrate that you verify attribution with a recorded-warning assertion, and that you would reach for the file-prefix skipping argument rather than counting frames through a deep internal chain.

for a principal

Treat deprecation ergonomics as a contract with downstream teams: warnings that name the caller's line, are visible in their default configuration, and land far enough ahead of removal to be actionable.

### What gets attributed When `warnings.warn()` runs, the machinery must answer "where did this come from?" It does so by walking the call stack. `stacklevel=1`, the default, means the frame that called `warn()`. `stacklevel=2` means that frame's caller, `3` its caller, and so on. From the chosen frame it takes three things: the filename and line number that appear in the printed `file:line: Category: message` prefix, and the module `__name__`, which is what filters match against. ### Why the default is usually wrong for a deprecation Consider a library function that warns with the default level. The user sees: ``` /site-packages/scorer/legacy.py:41: DeprecationWarning: load_rules() is deprecated warnings.warn("load_rules() is deprecated", DeprecationWarning) ``` That is the *library's* line. The user cannot fix it, and it does not tell them which of their own calls was the offender - which is the only fact they need. With `stacklevel=2` the same warning reports the caller's file and line, so the message points at the code that must change. This is the single most common reason a deprecation is described as unhelpful. ### The second, less obvious effect Attribution also decides **whether the warning is shown at all**. The default filter set ignores `DeprecationWarning` except for one entry whose module pattern is `__main__`. A warning attributed to the library module matches the `ignore` entry and is silently dropped; the same warning attributed to a top-level script matches the `__main__` entry and prints. So `stacklevel` is not just cosmetics - a library that gets it wrong makes its deprecations invisible to exactly the people who need them. It also interacts with any filter that uses the `module` field: `-W ignore:::my_service.legacy` only silences warnings that are *attributed* to that module. A third effect follows from the same attribution: the `default` action deduplicates per source location. With `stacklevel=2`, each distinct calling site produces its own printed warning, which is what you want; with `stacklevel=1`, every caller in the program collapses into a single message from a single library line, and the user sees one occurrence no matter how many places need fixing. ### Counting frames through wrappers The number is a raw frame count, so it changes whenever you add a layer. If the deprecated public function delegates to a private helper that emits the warning, the helper needs `stacklevel=3` to reach the original caller. Decorators add frames too. This makes hard-coded numbers brittle: refactor an indirection away and every warning silently starts pointing at the wrong file, with no error to tell you. Two mitigations. First, keep the `warn()` call in the public function rather than pushing it down into helpers, so the count stays 2. Second, on Python 3.12 and later, `warnings.warn` accepts `skip_file_prefixes`, a tuple of path prefixes; the machinery skips frames whose filenames start with any of them and blames the first frame outside. Passing your own package directory means the warning lands on the first caller outside your library no matter how many internal layers there are. It is the right tool for a framework with deep internal call chains. ### Checking it The cheap check is to write the smallest possible caller, emit the warning, and read the printed prefix: does it name the caller's file or yours? A test can assert this - `warnings.catch_warnings(record=True)` captures objects carrying `filename` and `lineno`, so a test can assert the recorded filename is the test file itself, which fails loudly if a future refactor inserts a frame. That assertion is cheap and it is the only thing that keeps the number honest over time. ### A common misreading `stacklevel` does not change *which* frames execute, does not truncate a traceback, and has no effect on performance. It also has nothing to do with recursion limits. It is purely a bookkeeping choice about which line of code gets named as the origin - but because filters and deduplication both key on that line, it changes behaviour, not only text.

  • A deprecated public function delegates to a private helper that emits the warning. What stacklevel does the helper need?
    Three: one frame for the helper's own `warn()` call, one for the public function, one to reach the external caller. Hard-coded counts like this break whenever a layer is added or removed, which is why Python 3.12's `skip_file_prefixes` argument - skipping every frame whose file starts with your package path - is more durable for deep call chains.
  • How would you test that a library's deprecation points at the caller rather than at the library?
    Capture it with `warnings.catch_warnings(record=True)` after setting an `always` filter, then assert that the recorded `filename` is the test module's own file. The recorded objects expose `filename`, `lineno`, `category` and `message`, so the assertion is direct and it fails the moment a refactor inserts an extra frame.
  • Does stacklevel affect whether the warning is displayed at all?
    Yes. Filters match on the *attributed* module, so a `DeprecationWarning` left at `stacklevel=1` is attributed to the library and hits the default `ignore` entry, while `stacklevel=2` can attribute it to `__main__` and make it visible. The per-location deduplication also keys on the attributed file and line, so the level decides whether every calling site reports or just one.

saying these in an interview costs you the question

  • Thinks stacklevel changes the traceback or control flow
  • Leaves the default on a library deprecation
  • Believes attribution is only cosmetic text
  • Hard-codes 2 inside a helper called through a wrapper
  • Confuses stacklevel with the recursion limit

context