A warning is a message from your library to a developer, about code that
works today and will not work tomorrow. That one sentence explains every design
decision in the warnings module, and explains why almost every mistake people
make with it comes from treating a warning like a log line.
Log lines are for operators, at runtime, about this run. Warnings are for developers, at development time, about the code. They are filtered differently, displayed differently, and — this is the part that catches people — shown at most once per source location by default, because a warning is about a line of code, not about an event.
Categories, and which to reach for
| category | use it when |
|---|---|
DeprecationWarning |
a feature aimed at developers is going away |
FutureWarning |
a feature aimed at end users will change behaviour |
UserWarning |
the default; a general “you probably did not mean this” |
RuntimeWarning |
dubious runtime behaviour (e.g. numeric overflow) |
SyntaxWarning |
dubious syntax that is nonetheless legal |
ResourceWarning |
an unclosed file or socket; ignored by default |
EncodingWarning |
a locale-dependent encoding was assumed |
The DeprecationWarning/FutureWarning distinction is about audience, and it
is the one people get wrong. If your library is consumed by other developers,
DeprecationWarning is correct — and it will be hidden by default outside
__main__, which is the intended behaviour, not a bug to work around. If your
“library” is an application whose users are not programmers and whose output
will change, FutureWarning is shown by default and is the right call.
stacklevel is the whole article
def connect(host: str, timeout_s: float | None = None) -> Conn:
if timeout_s is not None:
warnings.warn("timeout_s is deprecated; use timeout", DeprecationWarning)
...
That warning points at the line inside your library. The developer who needs to change something sees a file they do not own, a line they cannot edit, and no indication of where in their own code the problem is. So they ignore it. That is the entire mechanism by which deprecation warnings fail.
warnings.warn("timeout_s is deprecated; use timeout", DeprecationWarning, stacklevel=2)
stacklevel=2 attributes the warning to your caller — their file, their
line number, the thing they can actually fix. For a warning raised directly in
a public function, 2 is correct. If it is raised in a private helper called by
the public function, it is 3. Count the frames between warnings.warn and the
user’s code, and add one.
Getting this wrong is not cosmetic: the once-per-location default means the warning is deduplicated by your location rather than theirs, so a hundred different call sites produce one message.
💡Your public function delegates to a helper that may warn, and the helper is called from three different public entry points at different depths. How do you get stacklevel right?
click to reveal
You cannot with a constant, and that is the signal to restructure.
The clean fix is to warn at the public boundary, not in the shared helper. Each entry point knows its own depth (almost always 2), and the helper stays a pure function. This is usually a better factoring anyway: the deprecation belongs to the public API surface, which is the thing being deprecated.
If you truly cannot move it, warnings.warn(..., skip_file_prefixes=(os.path.dirname(__file__),)) (3.12+) attributes the warning to the first frame outside your package, which is what stacklevel was approximating all along. It is the right tool for a deeply nested helper.
The thing not to do is guess, or pick a number that happens to look right for one call path. A wrong stacklevel is worse than none, because it points confidently at innocent code.
simplefilter versus filterwarnings
Both push an entry onto the front of the filter list; they differ in specificity.
warnings.simplefilter("error") # everything becomes an exception
warnings.filterwarnings("error", category=DeprecationWarning,
module=r"myapp\.legacy") # only this, only there
simplefilter takes only an action and a category. filterwarnings also takes
a message regex and a module regex. Actions are "default", "error",
"ignore", "always", "module", "once" — and note that "default" means
once per location, which is why the same warning from a loop appears exactly
once and people conclude the module is broken.
The command line is often the better place: python -W error::DeprecationWarning
turns every deprecation into a hard failure, which is the correct setting for
CI and a terrible one for production.
The rule libraries must follow
A library must not install global filters at import time. Ever.
# In your package's __init__.py. Do not do this.
warnings.filterwarnings("ignore", category=DeprecationWarning)
Importing your package now silences deprecation warnings for the entire process, including ones from unrelated packages and from the standard library. You have taken a decision that belongs to the application and made it a side-effect of an import. It is one of the rudest things a library can do, and it is depressingly common.
Filters are application policy. Libraries emit; applications and test suites
decide what to do about it. If you want your users to see something, emit it
with the right category and the right stacklevel and let them configure the
rest.
💡Where *should* filter configuration live in a real project? click to reveal
Three places, in order of preference.
The test suite, most importantly. filterwarnings = ["error"] in your pytest config turns every warning into a test failure, with per-line ignore:: entries for the ones you have consciously accepted. That is the setting that keeps a codebase clean, because a new deprecation from any dependency breaks the build the day it appears rather than the day it is removed.
The application entry point — the main(), not any module it imports. This is where you decide that a noisy third-party DeprecationWarning should be suppressed in production, and it is visible to whoever is debugging the process.
The command line, -W, for one-off investigations. python -W always::DeprecationWarning app.py shows you every occurrence rather than one per location, which is what you want when auditing before a major upgrade.
What all three have in common: they belong to the person running the program, never to a library being imported by it.
catch_warnings is thread-unsafe
warnings.catch_warnings() saves and restores the global filter list. That
is fine in a single-threaded test and is a race condition everywhere else: one
thread entering the context manager changes the filters seen by every other
thread, and the restore on exit can clobber a change another thread made in the
meantime.
Python 3.14 adds context_aware_warnings, which makes the warnings state a
context variable so catch_warnings is thread-safe. But it is enabled by
default only on free-threaded builds; on a standard GIL build you have to
opt in. So on any interpreter you are likely to be running today, treat
catch_warnings as single-threaded-test-only. It is not a mechanism for
suppressing warnings around a call in a threaded server.
Summary
- Warnings are for developers about code; logs are for operators about events.
-
DeprecationWarningfor your library’s developers,FutureWarningfor end users of an application. -
stacklevel=2from a public function, and count frames if it is deeper. Without it, nobody acts on the warning. -
simplefilterfor broad actions,filterwarningsfor targeted ones,-Won the command line for one-offs. - Libraries emit; applications filter. Never install a global filter at import.
-
catch_warningsis thread-unsafe in practice — tests only.