Skip to content

Enabling asyncio Debug Mode in Tests and CI

asyncio's debug mode catches the bugs that otherwise reach production: coroutines that were never awaited, callbacks that block the loop, non-thread-safe calls from other threads, and resources finalised without being closed. It is far too slow for production — task-heavy code ran 37× slower with it enabled in a microbenchmark (3.76 s instead of 0.10 s for 50,000 tasks) — but a test suite is exactly where those costs are affordable. By default, though, debug mode only logs: a test with a never-awaited coroutine and a 150 ms blocking call printed a RuntimeWarning and an Executing <Task …> took 0.152 seconds warning and then passed. This guide turns those messages into test failures.

Prerequisites

1. Turn debug mode on for the whole test run

There are three switches, and in CI you want all of them:

PYTHONASYNCIODEBUG=1 python -X dev -m pytest
  • PYTHONASYNCIODEBUG=1 enables asyncio debug mode on every loop created in the process: slow-callback warnings, creation tracebacks on tasks and coroutines, thread-safety checks on call_soon.
  • -X dev enables Python's development mode, which turns on debug mode for asyncio as well and shows ResourceWarning and DeprecationWarning that are hidden by default — unclosed sockets and transports, and deprecated asyncio APIs among them.
  • Under pytest-asyncio the loop is created by the plugin; it honours PYTHONASYNCIODEBUG, and you can also enable debug per loop in a fixture with loop.set_debug(True).

Run once with these flags and read the output before making anything fatal: a mature codebase usually has a backlog of warnings to fix first.

Verify: a test that deliberately calls time.sleep(0.2) in a coroutine prints Executing <Task …> took 0.2xx seconds in the captured log.

What debug mode surfaces, and how it reports it A grid of 5 rows by 3 columns. What debug mode surfaces, and how it reports it problem reported as channel coroutine never awaited RuntimeWarning + creation site warnings callback or step over 100 ms Executing … took N seconds asyncio logger, WARNING call_soon from another thread RuntimeError exception unclosed transport or socket ResourceWarning warnings, with -X dev task exception never retrieved ERROR with creation traceback asyncio logger Two channels — warnings and the asyncio logger — each need their own rule to fail a test.

2. Make warnings fail the test

Warnings become errors through pytest's filter configuration. Escalate the categories debug mode uses, and keep a short, explicit allow-list for third-party noise:

# pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
    error::RuntimeWarning
    error::ResourceWarning
    error::DeprecationWarning:asyncio
    ignore:.*unclosed transport.*:ResourceWarning:some_vendor_lib

error::RuntimeWarning makes "coroutine … was never awaited" fail the test that triggered it — usually. The warning is emitted when the coroutine object is garbage-collected, which can be after the test function returns; pytest's unraisableexception plugin then attributes it to whichever test was running at the time, or reports it as a PytestUnraisableExceptionWarning. Running with -p no:randomly and -x when chasing one makes attribution reliable.

Keep each ignore line scoped to a module, with a comment linking to the upstream issue; blanket ignores are how the backlog comes back.

Verify: a test containing a bare forgot() call for an async function now fails with RuntimeWarning: coroutine 'forgot' was never awaited.

3. Fail tests on slow-callback and loop error logs

Slow callbacks and unretrieved task exceptions are log records on the asyncio logger, not warnings, so filters do not see them. Capture them with an autouse fixture:

# conftest.py
import asyncio
import logging
import pytest


@pytest.fixture(autouse=True)
def fail_on_asyncio_errors(caplog):
    caplog.set_level(logging.WARNING, logger="asyncio")
    yield
    bad = [
        r for r in caplog.get_records("call")
        if r.name == "asyncio"
        and (r.levelno >= logging.ERROR or "took" in r.getMessage())
    ]
    if bad:
        pytest.fail("asyncio reported problems:\n" + "\n".join(r.getMessage() for r in bad))

The fixture catches both Executing … took N seconds (WARNING) and Task exception was never retrieved / Exception in callback (ERROR). The default threshold for "slow" is 100 ms, set by loop.slow_callback_duration. In CI, where machines are shared and noisy, raise it rather than disable the check:

@pytest.fixture(autouse=True)
async def relaxed_slow_threshold():
    asyncio.get_running_loop().slow_callback_duration = 0.25   # 250 ms on shared CI runners
    yield

A threshold that fires on CI jitter trains people to ignore it; one at 250 ms still catches every real blocking call — synchronous HTTP, time.sleep, large file reads — because those take far longer. Production-grade tuning is covered in tracing slow callbacks with loop.slow_callback_duration.

Verify: a test that blocks the loop for 300 ms fails with the "took" message in the failure output.

From debug output to a red build A flow of 4 stages. From debug output to a red build PYTHONASYNCIODEBUG=1, -X dev produce the reports filterwarnings = error warnings fail autouse caplog fixture log records fail red build names the test and the call Debug mode only reports; the build fails because warnings and log records are escalated.

4. Keep the cost out of the fast loop

At 37× the cost on task-heavy code, debug mode can turn a 2-minute suite into a much longer one, depending on how much of the time is asyncio overhead. Split the runs:

# .github/workflows/test.yml (excerpt)
jobs:
  tests:
    steps:
      - run: python -m pytest -q                                   # fast, every push
  tests-asyncio-debug:
    steps:
      - run: PYTHONASYNCIODEBUG=1 python -X dev -m pytest -q -p no:cacheprovider
        env:
          PYTHONTRACEMALLOC: "20"                                  # allocation sites in warnings

PYTHONTRACEMALLOC=20 makes every ResourceWarning and "never awaited" warning include the 20-frame traceback of where the object was allocated, instead of the Enable tracemalloc to get the object allocation traceback hint. It adds further overhead, which is another reason to keep this job separate. Run the debug job on every pull request; the plain job gives the fast feedback.

Verify: the debug job's duration is acceptable for a PR check, and its failures include allocation tracebacks.

Cost of debug mode on task-heavy code 2 horizontal bars comparing debug mode on with the others. Cost of debug mode on task-heavy code debug mode on 3.76 s debug mode off 0.10 s 50,000 no-op tasks gathered in batches of 100; Python 3.14. The worst case for debug mode; I/O-heavy suites slow down far less, but it still belongs in its own CI job.

5. Add a leaked-task check while you are there

Debug mode does not report tasks that are still pending when a test ends — they are silently cancelled when the loop closes. Add that check in the same conftest:

@pytest.fixture(autouse=True)
async def no_leaked_tasks():
    before = asyncio.all_tasks()
    yield
    await asyncio.sleep(0)                               # let finished tasks settle
    leaked = {t for t in asyncio.all_tasks() - before if not t.done()}
    leaked.discard(asyncio.current_task())
    for t in leaked:
        t.cancel()
    assert not leaked, f"tasks leaked: {[t.get_name() for t in leaked]}"

Together with debug mode this covers the three classic asyncio test escapes: never awaited, blocking the loop, and never finished. The full treatment, including fixture-scoped loops, is in detecting leaked tasks in tests.

Verify: a test that starts a background task and returns without cancelling it fails with that task's name.

Verification

Debug mode is pulling its weight when:

  • A never-awaited coroutine fails a test, with the allocation site in the output.
  • A blocking call over the threshold fails a test, naming the task.
  • Unretrieved task exceptions and callback errors fail a test.
  • The debug job runs on every pull request without slowing the fast feedback loop.

Diagnostic Hook: track the debug job's failure categories over time — never-awaited, slow callback, resource warning, leaked task. A category that keeps reappearing points to a code pattern worth a lint rule; slow-callback failures that cluster in one module usually mean a synchronous client slipped into async code.

Pitfalls & edge cases

  • Warnings attributed to the wrong test. Garbage collection timing decides when "never awaited" fires; reproduce with -x and a fixed test order.
  • Thresholds tuned for a laptop. Shared CI runners need a higher slow_callback_duration, or the check becomes flaky and gets disabled.
  • Ignoring warnings globally to get green. Scope every ignore to a module and a message.
  • Debug mode in production. The overhead is real; enable it per-process for diagnosis only.

Frequently Asked Questions

How do I enable asyncio debug mode in tests?

Set PYTHONASYNCIODEBUG=1 or run Python with -X dev, which also shows ResourceWarning. Under pytest-asyncio the plugin's loop honours the environment variable, or call loop.set_debug(True) in a fixture.

How do I make 'coroutine was never awaited' fail my tests?

Add error::RuntimeWarning to pytest's filterwarnings. The warning then becomes an exception, reported against the test that was running when the coroutine was garbage-collected.

How do I fail a test when the event loop is blocked?

Debug mode logs "Executing ... took N seconds" on the asyncio logger when a step exceeds slow_callback_duration. Capture the asyncio logger with an autouse caplog fixture and call pytest.fail if such a record appears.

Is asyncio debug mode safe to use in production?

It is safe but slow: task-heavy code ran about 37 times slower in a microbenchmark. Use it in CI and for targeted diagnosis, not as a permanent production setting.