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¶
- Python 3.11+ and pytest with pytest-asyncio (or AnyIO's plugin).
- What debug mode reports, from finding blocking calls with asyncio debug mode.
- Async test setup, from testing asyncio code with pytest-asyncio.
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=1enables asyncio debug mode on every loop created in the process: slow-callback warnings, creation tracebacks on tasks and coroutines, thread-safety checks oncall_soon.-X devenables Python's development mode, which turns on debug mode for asyncio as well and showsResourceWarningandDeprecationWarningthat 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 withloop.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.
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.
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.
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
-xand 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.
Related¶
- Event Loop Debugging & Instrumentation — up to the topic overview.
- Debugging unawaited coroutines in large codebases — tracking down the warnings this surfaces.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.