Warnings

Not every problem should stop the program — the warnings module lets code flag something suspicious (deprecated usage, risky behavior) without raising an exception.

Exceptions interrupt execution; sometimes that's too strong a response to a real but non-fatal issue — using a deprecated function, calling something in a way that works today but won't tomorrow, passing an unusual-but-valid argument. The standard library warnings module lets code emit a message about such situations while execution continues normally, and gives both library authors and application authors fine control over which warnings are shown, ignored, or escalated.

Warnings vs Exceptions

An exception, once raised, must be handled or it terminates the program (or the current thread/task) — it represents a condition the current code cannot proceed past correctly. A warning is informational: warnings.warn(...) prints a message (by default, to stderr) and execution continues immediately afterward, unless the warning system is explicitly configured to turn it into an error.

Unlike a raised exception, the call to old_function() still returns 42 normally — the warning is a side note, not a control-flow interruption.

import warnings

def old_function():
    warnings.warn(
        "old_function is deprecated, use new_function instead",
        DeprecationWarning,
        stacklevel=2,
    )
    return 42

print(old_function())  # prints a warning to stderr, then 42

warnings.warn

warnings.warn(message, category=UserWarning, stacklevel=1) issues a warning. category is a warning class (see below) that lets callers filter by kind. stacklevel controls which frame the warning is reported as coming from — stacklevel=2 attributes it to the *caller* of the function issuing the warning, which is almost always what you want for a library warning about deprecated usage.

stacklevel=2 makes the warning's reported file/line point at the code that CALLED risky_default(), not the line inside risky_default() itself — much more actionable for whoever sees it.

def risky_default(x=None):
    if x is None:
        warnings.warn(
            "calling risky_default() with no argument is discouraged",
            UserWarning,
            stacklevel=2,
        )
        x = []
    return x

Built-in Warning Categories

Warning categories are exception classes (warnings integrate with the exception hierarchy) rooted at Warning, itself a subclass of Exception. The standard library defines several built-in categories for common situations.

DeprecationWarning is silenced by default in most contexts except code run as __main__ or in test runners, because it's meant for developers of the calling code, not end users — an easy source of confusion when a DeprecationWarning 'doesn't show up'.

You can define custom warning categories the same way as custom exceptions: class DataQualityWarning(UserWarning): pass — subclassing an existing category, not Warning directly, unless you have a specific reason to.

Warning
 +-- UserWarning         # default category; general-purpose
 +-- DeprecationWarning  # feature will be removed; aimed at developers
 +-- PendingDeprecationWarning  # will become a DeprecationWarning later
 +-- SyntaxWarning       # dubious syntax
 +-- RuntimeWarning      # dubious runtime behavior
 +-- FutureWarning       # aimed at end users of an application, not just devs
 +-- ResourceWarning     # resource usage issue (e.g. unclosed file)

Filtering Warnings

Python maintains a filter list controlling what happens to each warning: show it (default, usually once per location), always show it, ignore it, turn it into an error (raise it as an exception), or show it only the first time. warnings.filterwarnings(action, category=..., module=...) adds a filter rule; warnings.simplefilter(action) sets a blanket rule.

Filters are checked in order added (most specific usually added last, since later-added filters take precedence); 'error' converts matching warnings into raised exceptions instead of printed messages.

import warnings

warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("error", category=RuntimeWarning)
warnings.simplefilter("always")  # show every warning, every time (e.g. useful in tests)

Turning Warnings into Errors Temporarily

warnings.catch_warnings() is a context manager that saves and restores the warning filter state, so temporary filter changes don't leak beyond a block — essential in tests that want to assert a warning was raised, or code that wants to escalate warnings to exceptions only locally.

Because 'error' was set inside catch_warnings(), the DeprecationWarning became a raised exception catchable with a normal except clause — and the filter change is undone on exit.

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("error")
    try:
        old_function()
    except DeprecationWarning:
        print("caught the deprecation warning as an exception")
# outside the block, the original filters are restored automatically

Testing That a Warning Was Raised

warnings.catch_warnings(record=True) returns a list that captured warning objects are appended to instead of being printed, which is the standard pattern for asserting a function issues the right warning in a test.

record=True redirects warnings into the caught list instead of stderr; simplefilter('always') ensures the warning isn't suppressed by a 'once per location' default before it can be captured.

Third-party test frameworks like pytest (an ecosystem tool, not stdlib) provide their own higher-level helper (pytest.warns(...)) built on the same underlying mechanism.

import warnings

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    old_function()
    assert len(caught) == 1
    assert issubclass(caught[0].category, DeprecationWarning)
    assert "deprecated" in str(caught[0].message)

Practical Usage: Deprecating an API

The standard pattern for deprecating a function or parameter: keep the old interface working, emit a DeprecationWarning pointing at the replacement, and remove the old interface in a later release once users have had time to migrate.

Existing callers using 'secure' keep working (no breakage), but get a clear, filterable signal to migrate before the old parameter is eventually removed.

def connect(host, port, use_ssl=None, secure=None):
    if secure is not None:
        warnings.warn(
            "'secure' is deprecated, use 'use_ssl' instead",
            DeprecationWarning,
            stacklevel=2,
        )
        use_ssl = secure
    # ... proceed with use_ssl

Key Takeaways

warnings.warn(message, category) reports a non-fatal issue and lets execution continue, unlike raising an exception.

Warning categories (UserWarning, DeprecationWarning, RuntimeWarning, ...) are Exception subclasses rooted at Warning, and are used to filter warnings by kind.

warnings.filterwarnings / simplefilter control whether a warning is shown, ignored, shown always, or turned into an error.

warnings.catch_warnings() scopes filter changes to a block and restores prior state afterward; record=True captures warnings into a list for testing.

Use stacklevel=2 (or higher) so a warning is attributed to the caller's code, not the internal line that issued it.

def old(): warnings.warn("deprecated") # missing category and stacklevel def old(): warnings.warn("deprecated", DeprecationWarning, stacklevel=2)

Omitting the category defaults to UserWarning (often not what's intended for deprecations), and omitting stacklevel attributes the warning to the wrong line (inside old(), not the caller).

warnings.simplefilter("ignore") # set globally at import time, hides everything forever with warnings.catch_warnings(): warnings.simplefilter("ignore") call_noisy_function()

A global simplefilter('ignore') at module import time silences that warning category everywhere for the rest of the process; scope filter changes with catch_warnings() so they don't leak.

How does a warning differ from a raised exception in terms of program flow?

A raised exception interrupts execution and must be caught or the program terminates; a warning (by default) just prints a message and execution continues immediately, unless a filter has been set to turn that warning into an error.

Why is DeprecationWarning sometimes invisible even though the code clearly calls warnings.warn with that category?

DeprecationWarning is ignored by default outside of __main__ and test runners, specifically because it's meant for developers of calling code rather than end users; simplefilter('always') or running as __main__ will surface it.

What does warnings.catch_warnings() do, and why use it instead of just calling simplefilter directly?

It's a context manager that saves the current warning filter state on entry and restores it on exit, so temporary changes (like turning warnings into errors for a test) don't leak and affect the rest of the program.

What is the purpose of the stacklevel argument to warnings.warn?

It controls which stack frame the warning is reported as originating from; stacklevel=2 attributes it to the caller of the warning-issuing function, which is what a user needs to find and fix the offending call site.

How would you write a test asserting that calling a function issues a specific warning?

Use with warnings.catch_warnings(record=True) as caught:, set warnings.simplefilter('always') inside the block, call the function, then assert on the contents of caught (e.g. len(caught), caught[0].category, str(caught[0].message)).

What is the base class of all built-in warning categories, and what is IT a subclass of?

All warning categories derive from Warning, which is itself a subclass of Exception — meaning warnings integrate into the exception hierarchy and can be caught with except Warning: if the filter turns them into errors.

Write a function divide(a, b) that issues a RuntimeWarning (not an exception) when b is 0.0 and returns float('inf') or float('-inf') accordingly, but still raises ZeroDivisionError-style behavior only for true zero-division with ints.

import warnings def divide(a: float, b: float) -> float: if b == 0.0: warnings.warn("dividing by zero, returning inf", RuntimeWarning, stacklevel=2) return float("inf") if a >= 0 else float("-inf") return a / b

Instead of crashing, division by zero for floats emits a RuntimeWarning and returns a signed infinity, letting the caller continue while still being alerted to the unusual input.

What does this print?

It prints '1 UserWarning' — the warning is captured (not printed to stderr) because record=True, and simplefilter('always') ensures it isn't suppressed.

Write code that temporarily turns all DeprecationWarnings into raised exceptions only within a with block, without affecting warning behavior outside it.

import warnings with warnings.catch_warnings(): warnings.filterwarnings("error", category=DeprecationWarning) try: old_function() except DeprecationWarning as exc: print(f"escalated: {exc}") catch_warnings() scopes the filter change so 'error' for DeprecationWarning only applies inside the with block; outside it, the previous (default) filter state is automatically restored.

import warnings
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    warnings.warn("test", UserWarning)
    print(len(w), w[0].category.__name__)