Python’s warnings.catch_warnings() is convenient in tests, compatibility shims, and small diagnostic scopes. It lets code temporarily change warning filters and then restore the previous state.

That model becomes harder to reason about when several threads or asynchronous tasks use it at the same time. Historically, catch_warnings() manipulated process-global state in the warnings module. Two overlapping contexts could therefore interfere with each other.

Python 3.14 adds an opt-in context-aware mode that changes this behavior. When sys.flags.context_aware_warnings is true, catch_warnings() stores its filtering state in a context variable instead of mutating the same global warning state for every concurrent execution path.

This is especially useful for concurrent test suites and services, but enabling the flag is only part of the design. You also need to understand context propagation, thread creation, warning capture, subprocesses, and compatibility with older Python releases.

Check the runtime mode first

The active behavior is visible through sys.flags:

import sys

print(sys.flags.context_aware_warnings)

When the value is true, warnings.catch_warnings() uses the concurrency-safe behavior introduced in Python 3.14.

When it is false, catch_warnings() retains the traditional behavior based on global warning-module state.

That distinction matters because the default is not identical across every CPython build. In Python 3.14, context-aware warnings default to true for free-threaded builds and false for GIL-enabled builds.

Do not write a test suite that silently assumes one default if it runs against both build configurations.

Enable the behavior explicitly when your application depends on it

Python 3.14 exposes a command-line switch:

python -X context_aware_warnings=1 -m pytest

The corresponding environment variable is PYTHON_CONTEXT_AWARE_WARNINGS.

For CI, an explicit setting is often easier to audit than relying on the interpreter build’s default. It turns concurrency-safe warning scopes into a declared runtime requirement rather than an accidental property of one test runner image.

If you deliberately need the old behavior while investigating a compatibility problem, the same flag can be set to false.

Why overlapping warning contexts were risky

Consider two threads that each want a different warning policy:

import warnings


def strict_operation():
    with warnings.catch_warnings():
        warnings.simplefilter("error", DeprecationWarning)
        run_legacy_operation()


def quiet_operation():
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", DeprecationWarning)
        run_compatibility_probe()

Conceptually, these functions request independent policies.

With context-aware warnings disabled, however, catch_warnings() modifies global warning state. If the scopes overlap in different threads, one thread can observe changes made for another thread. The Python documentation describes concurrent use in that mode as unsafe.

With context-aware warnings enabled, the temporary filter state is kept in a ContextVar. The scopes can then remain associated with their own execution contexts.

The important shift is from “temporarily mutate global policy” to “temporarily establish policy for this context.”

Asyncio tasks are part of the same problem

Concurrency does not require OS threads.

Two coroutines can overlap while each uses a warning context:

import asyncio
import warnings


async def expect_deprecation():
    with warnings.catch_warnings(record=True) as seen:
        warnings.simplefilter("always", DeprecationWarning)
        await asyncio.sleep(0)
        warnings.warn("old API", DeprecationWarning)
        return list(seen)


async def ignore_deprecation():
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", DeprecationWarning)
        await asyncio.sleep(0)
        warnings.warn("compatibility path", DeprecationWarning)


async def main():
    captured, _ = await asyncio.gather(
        expect_deprecation(),
        ignore_deprecation(),
    )
    assert len(captured) == 1


asyncio.run(main())

The await points make the overlap visible. A task can establish a warning policy, suspend, and let another task establish a different policy before the first resumes.

Context-aware mode is designed to make catch_warnings() safe for this kind of coroutine and task concurrency.

Recording warnings also changes internally

catch_warnings(record=True) is common in tests:

import warnings

with warnings.catch_warnings(record=True) as seen:
    warnings.simplefilter("always")
    warnings.warn("check me")

assert len(seen) == 1

When context-aware warnings are disabled, recording works by temporarily replacing the module’s showwarning() function and restoring it afterward. That replacement is another global mutation and is therefore not safe for overlapping concurrent contexts.

When context-aware warnings are enabled, recording status is tracked in the context-aware warning state instead. The standard showwarning() function is not replaced merely to implement record=True.

This difference is mostly an implementation detail until code itself replaces warnings.showwarning or makes assertions about its identity.

Avoid tests that assert the old implementation mechanism

A brittle test might do this:

import warnings

original = warnings.showwarning

with warnings.catch_warnings(record=True):
    assert warnings.showwarning is not original

That is not a useful public contract for Python 3.14 context-aware mode.

Test the observable behavior instead:

import warnings

with warnings.catch_warnings(record=True) as seen:
    warnings.simplefilter("always")
    warnings.warn("hello", UserWarning)

assert len(seen) == 1
assert seen[0].category is UserWarning
assert str(seen[0].message) == "hello"

Behavior-focused tests survive changes in how the warning subsystem implements isolation.

Context safety and context inheritance are separate questions

A ContextVar can isolate state, but a newly created thread does not necessarily inherit the parent’s context.

Python 3.14 exposes another runtime flag:

import sys

print(sys.flags.thread_inherit_context)

When thread_inherit_context is true, a thread created with threading.Thread starts with a copy of the context of the caller that starts it.

This matters when a parent thread establishes a warning policy and then starts a worker inside that scope.

For example:

import threading
import warnings


def worker():
    warnings.warn("legacy", DeprecationWarning)


with warnings.catch_warnings():
    warnings.simplefilter("error", DeprecationWarning)
    thread = threading.Thread(target=worker)
    thread.start()
    thread.join()

Whether the worker inherits the warning context depends on context inheritance policy. Context-aware storage prevents unrelated concurrent scopes from corrupting each other; it does not by itself promise that every newly started thread receives the parent’s current warning filters.

Treat those as two independent requirements.

Make inheritance policy explicit in concurrency-sensitive deployments

Python 3.14 provides -X thread_inherit_context and a corresponding environment setting for controlling thread context inheritance.

If an application expects warning policy established around thread creation to propagate into those threads, verify this flag as part of startup.

A small guard can fail early:

import sys


def verify_warning_runtime() -> None:
    if not sys.flags.context_aware_warnings:
        raise RuntimeError("context-aware warnings must be enabled")
    if not sys.flags.thread_inherit_context:
        raise RuntimeError("thread context inheritance must be enabled")

Not every program needs both settings. A worker system that establishes warning filters independently inside each worker may only need context-aware isolation.

The correct policy follows the ownership model of the warning filters.

Do not confuse the GIL with warning-state isolation

A GIL-enabled CPython build can still have multiple threads whose operations interleave.

The issue is not simply two Python bytecodes executing simultaneously. The problem is that separate logical operations can enter overlapping catch_warnings() scopes and mutate shared state at different times.

Therefore, “we still use the GIL” is not a reason to treat overlapping global warning contexts as safe.

Likewise, free-threaded Python does not magically make application-level state safe. Python 3.14 changes the warning implementation specifically so that catch_warnings() can use context-local state when the feature is enabled.

Keep global warning configuration global

Context-aware catch_warnings() is best suited to temporary policy.

Application-wide startup configuration can still be expressed once:

import warnings

warnings.filterwarnings(
    "error",
    category=ResourceWarning,
)

Then narrower operations can use catch_warnings() to establish temporary exceptions or capture behavior.

Do not wrap an application’s entire lifetime in a temporary warning context merely because context-aware mode exists. Permanent policy and scoped policy solve different problems.

Use narrow scopes

Even with concurrency-safe behavior, smaller warning scopes are easier to understand:

import warnings


def call_old_dependency():
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", DeprecationWarning)
        return dependency.old_entrypoint()

This is preferable to suppressing DeprecationWarning around a large request handler or worker loop.

A narrow scope reduces the chance that an unrelated warning is hidden and makes future removal of the compatibility exception straightforward.

Concurrency safety does not make broad suppression good policy.

Prefer specific categories and messages

A temporary filter can target the warning you actually expect:

import warnings

with warnings.catch_warnings():
    warnings.filterwarnings(
        "ignore",
        message=r"legacy codec is deprecated",
        category=DeprecationWarning,
    )
    use_legacy_codec()

A blanket simplefilter("ignore") may suppress ResourceWarning, custom application warnings, or deprecations from unrelated dependencies.

The narrower the filter, the more useful unexpected warnings remain.

Turn warnings into errors for strict boundaries

Concurrency-safe warning contexts are useful for more than suppression.

A migration test can make a particular warning fatal:

import warnings


def test_new_path_has_no_deprecation_warning():
    with warnings.catch_warnings():
        warnings.simplefilter("error", DeprecationWarning)
        run_new_path()

This style avoids inspecting stderr and fails at the point where the warning is emitted.

For a large test suite, it can also be useful to enable warnings globally in CI and reserve local contexts for the few tests that intentionally exercise deprecated behavior.

Capture warnings without depending on ordering across tasks

Concurrent code can produce warnings in nondeterministic order.

Avoid an assertion like this when two independent operations can race:

assert [str(item.message) for item in seen] == [
    "worker A",
    "worker B",
]

If ordering is not part of the contract, compare an order-independent representation:

messages = {str(item.message) for item in seen}
assert messages == {"worker A", "worker B"}

Context-aware warnings solve state isolation. They do not impose deterministic scheduling on threads or asyncio tasks.

Be careful when a task outlives the warning scope

Consider creating asynchronous work inside a context and awaiting it later:

import asyncio
import warnings


async def emit_later():
    await asyncio.sleep(0.1)
    warnings.warn("late", UserWarning)


async def main():
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        task = asyncio.create_task(emit_later())

    await task

Context variables are copied into a newly created asyncio task according to normal context propagation behavior. That means lexical indentation alone is not always enough to tell you what context a task carries after it has been created.

For maintainability, prefer keeping task lifetime inside the policy scope when the warning policy is conceptually scoped to that work:

async def main():
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        task = asyncio.create_task(emit_later())
        await task

Structured lifetimes make context ownership easier to review.

Thread pools deserve explicit tests

Long-lived worker threads are different from threads created inside a warning scope.

A ThreadPoolExecutor may create workers before a request-specific warning context exists, then reuse those workers for later jobs. Do not assume that a context established in the submitting thread automatically becomes the worker’s context merely because thread inheritance is enabled.

If warning policy must travel with each submitted job, test the executor behavior you rely on and consider establishing the warning context inside the submitted callable.

For example:

import warnings


def run_strict_job(job):
    with warnings.catch_warnings():
        warnings.simplefilter("error", DeprecationWarning)
        return job()

This makes ownership local to the worker operation instead of depending on when the worker thread was originally created.

Subprocesses are separate runtimes

Context variables do not cross a process boundary automatically.

If a test starts another Python process, configure that interpreter independently:

import os
import subprocess
import sys


env = os.environ.copy()
env["PYTHON_CONTEXT_AWARE_WARNINGS"] = "1"

subprocess.run(
    [sys.executable, "-m", "myapp.worker"],
    env=env,
    check=True,
)

The same principle applies to CI workers, process pools, containers, and service supervisors. Runtime flags belong to each interpreter process.

Do not use warning filters as synchronization

A warning context is policy state, not a lock or task barrier.

Do not build logic where one thread enters a filter scope to signal another thread:

# Do not use warning configuration as cross-thread signaling.
with warnings.catch_warnings():
    warnings.simplefilter("error", UserWarning)
    signal_worker()

Use threading.Event, queues, asyncio synchronization primitives, or another explicit coordination mechanism.

Context-aware warnings intentionally make warning state less suitable for accidental cross-context communication.

Custom showwarning hooks remain global design decisions

Applications sometimes replace warnings.showwarning to route warning output into structured logging.

That is different from using catch_warnings(record=True).

If your application assigns a custom warnings.showwarning, treat that hook as process-level configuration and install it in a controlled startup phase. Do not repeatedly swap global hooks from concurrent request handlers.

Context-aware recording reduces one source of global hook mutation, but it does not turn arbitrary application mutations of warnings.showwarning into context-local operations.

Test the flag itself when isolation is a requirement

If concurrent warning isolation is required for correctness, make the environment contract executable:

import sys


def test_context_aware_warnings_enabled():
    assert sys.flags.context_aware_warnings

This catches CI jobs that forgot the interpreter option and deployment images that changed startup configuration.

For a library, however, asserting a process-wide flag is usually too strong. Libraries should avoid dictating interpreter startup policy unless that requirement is explicitly part of their supported environment.

Libraries should degrade deliberately

A reusable library may support Python 3.13 and Python 3.14.

Before Python 3.14, sys.flags.context_aware_warnings does not exist and catch_warnings() behaves like the non-context-aware mode.

Capability detection can be centralized:

import sys


def has_context_aware_warnings() -> bool:
    return bool(
        getattr(sys.flags, "context_aware_warnings", False)
    )

Do not claim concurrency-safe local warning capture on runtimes where the capability is unavailable.

If the feature is essential, raise the minimum supported Python version and document the required startup flag. If it is only an optimization for test isolation, serialize the affected tests or avoid overlapping warning contexts on older runtimes.

Avoid changing process startup flags from library code

context_aware_warnings is an interpreter startup setting, not an ordinary mutable application option.

A library should not try to emulate changing sys.flags at runtime. Instead, inspect the capability and choose a safe code path.

This separation is useful operationally: deployment configuration decides interpreter semantics, while application code decides how to behave under those semantics.

Build a concurrency regression test

A useful test should force warning contexts to overlap rather than merely start two workers and hope the scheduler interleaves them.

A barrier can coordinate the critical section:

import threading
import warnings


def test_overlapping_warning_contexts():
    barrier = threading.Barrier(2)
    results = {}

    def strict():
        try:
            with warnings.catch_warnings():
                warnings.simplefilter("error", UserWarning)
                barrier.wait()
                warnings.warn("strict", UserWarning)
        except UserWarning:
            results["strict"] = "raised"

    def quiet():
        with warnings.catch_warnings(record=True) as seen:
            warnings.simplefilter("ignore", UserWarning)
            barrier.wait()
            warnings.warn("quiet", UserWarning)
            results["quiet"] = len(seen)

    a = threading.Thread(target=strict)
    b = threading.Thread(target=quiet)
    a.start()
    b.start()
    a.join()
    b.join()

    assert results["strict"] == "raised"
    assert results["quiet"] == 0

Run this kind of test only under a runtime configuration whose semantics you intentionally support. Its purpose is to verify isolation, not to produce a flaky race on configurations known to use global warning state.

Test asyncio overlap separately

Thread tests and asyncio tests exercise different scheduling and propagation paths.

An async regression test can synchronize tasks with an event:

import asyncio
import warnings


async def strict(ready, proceed):
    with warnings.catch_warnings():
        warnings.simplefilter("error", UserWarning)
        ready.set()
        await proceed.wait()
        try:
            warnings.warn("strict", UserWarning)
        except UserWarning:
            return "raised"
        return "missed"


async def quiet(ready, proceed):
    await ready.wait()
    with warnings.catch_warnings(record=True) as seen:
        warnings.simplefilter("ignore", UserWarning)
        proceed.set()
        await asyncio.sleep(0)
        warnings.warn("quiet", UserWarning)
        return len(seen)

The synchronization makes the overlap intentional and documents what the test is proving.

Keep warning assertions focused on public fields

Objects recorded by catch_warnings(record=True) expose useful attributes including message, category, filename, and lineno.

Use those documented fields rather than depending on the concrete internal type:

with warnings.catch_warnings(record=True) as seen:
    warnings.simplefilter("always")
    warnings.warn("migration", FutureWarning)

item = seen[0]
assert item.category is FutureWarning
assert str(item.message) == "migration"
assert isinstance(item.filename, str)
assert isinstance(item.lineno, int)

This keeps tests about warning behavior rather than implementation details.

Keep the warning registry in mind

Warning filters such as default, module, and once suppress repeated warnings according to registry state.

A test that expects the same warning to be observed repeatedly can fail for reasons unrelated to context-aware isolation if the warning was already registered as emitted.

For deterministic capture tests, simplefilter("always") is often appropriate:

with warnings.catch_warnings(record=True) as seen:
    warnings.simplefilter("always", DeprecationWarning)
    call_deprecated_api()
    call_deprecated_api()

assert len(seen) == 2

Choose the filter action that matches the behavior being tested.

Migration checklist

A codebase adopting Python 3.14 context-aware warnings can use a small sequence:

  1. Find uses of warnings.catch_warnings(), especially record=True.
  2. Identify scopes that can overlap across threads or asyncio tasks.
  3. Decide whether context-aware warnings are a required runtime policy.
  4. Configure -X context_aware_warnings=1 explicitly where required.
  5. Decide separately whether newly created threads must inherit the current context.
  6. Remove tests that depend on temporary replacement of warnings.showwarning.
  7. Add deterministic overlap tests for the concurrency models you use.
  8. Configure subprocesses independently.
  9. Keep suppression scopes narrow and filters specific.
  10. Maintain a deliberate fallback for Python versions before 3.14.

The main design rule

Python 3.14’s context-aware warnings feature solves a precise problem: temporary warning policy can be stored in execution context instead of shared global state, making catch_warnings() predictable when concurrent scopes overlap.

It does not make every warning-related mutation local. It does not guarantee that a new thread inherits its creator’s context. It does not propagate configuration into subprocesses. And it does not make broad warning suppression a good idea.

Use context-aware warnings as one part of a clear ownership model: establish temporary filters in narrow scopes, make context inheritance explicit where needed, test real overlap, and keep process-wide warning hooks and startup policy separate from request- or task-local behavior.