Debugging unawaited coroutines in large codebases¶
The symptom is maddeningly specific: your logs carry RuntimeWarning: coroutine 'sync_user' was never awaited, but the line number points at a garbage-collection cycle in an unrelated request, not the call site that created the orphan. An unawaited coroutine is an async def that was called but never awaited or scheduled with asyncio.create_task(). It does no work, holds its closure and arguments in memory until the garbage collector (GC) finalizes it, and — worst of all — swallows any exception it would have raised. In a large codebase where coroutines flow through middleware, dependency-injection containers, and thread pools, the warning's timestamp is useless for locating the bug. This guide gives a systematic workflow to reconstruct the origin, then make the regression impossible.
Prerequisites¶
- Python 3.11+. The detection techniques work on 3.8+, but the examples use
asyncio.TaskGroupand modern typing. Finalization-based warnings became more deterministic in 3.10+. - A reproducible run. You need a test or staging invocation that emits the warning, ideally under
asyncio.run(). - Familiarity with the patterns that prevent leaks. This is a detail page under Coroutine Design Patterns; the ownership rules there (every task has an owner) are what these techniques enforce. For how the scheduler tracks tasks at all, see Asyncio Fundamentals & Event Loop Architecture.
The fix is always to attach origin metadata at creation time so that when the warning fires later, you can read back where it came from. The steps below build that capability up from a one-line debug flag to a CI gate.
1. Reproduce under debug mode and confirm the signature¶
Set PYTHONASYNCIODEBUG=1 (or call loop.set_debug(True)) before the run. Debug mode makes asyncio attach the creation traceback to coroutine and task objects and emit warnings more eagerly, so the warning text starts including the source line. It slows execution ~15-20%, so keep it to staging and diagnostic runs.
Verify: the run prints RuntimeWarning: coroutine 'sync_user' was never awaited, and under debug mode it is followed by a Coroutine created at traceback pointing at the sync_user(42) line. Confirm the flag is live with asyncio.get_running_loop().get_debug() is True.
2. Capture the creation stack with a custom task factory¶
For orphans that do reach create_task() but are never awaited (the common large-codebase case), install a task factory that records where each task was born and screams if the GC reclaims it unfinished. This turns "name only" warnings into full call sites.
Verify: on shutdown the log shows Task orphan GC'd before completion followed by the exact stack that called create_task(). Bind a contextvars request/trace ID inside the factory to correlate the orphan with the request that spawned it.
3. Reconstruct the async call chain past await boundaries¶
Standard tracebacks truncate at await. To rebuild the synchronous chain that instantiated an orphan, read the coroutine's own frame and walk f_back; to find what is retaining it, ask the GC for referrers.
Verify: the output names the defining frame and identifies the retaining container (here, a list). In real code gc.get_referrers() typically points at a DI registry, a closure cell, or a forgotten task list — that is your culprit.
4. Audit a scope for leaked tasks with an async context manager¶
Wrap a region of code to diff all_tasks() before and after. Anything new and unfinished at scope exit is a leak; cancel and drain it so the leak is contained, not silently abandoned.
Verify: the scope logs Leaked task in scope: forgotten and the task count returns to baseline after the block, proving the orphan was caught and drained rather than left running.
5. Turn the warning into a hard failure in CI¶
Make regressions impossible by elevating the RuntimeWarning to an error in the test suite and adding a static rule in pre-commit. Convert at the warning filter so the failing test points at the offending coroutine.
For the static side, enable a linter rule that flags a bare coroutine call whose result is discarded — Ruff's RUF006 (store a reference to create_task results) and the flake8-async / ASYNC rule family catch the most common shapes in a pre-commit hook. Run the suite with mypy --strict so an async def returning Coroutine[...] used in a sync context is a type error.
Verify: introducing an unawaited coroutine now fails CI with RuntimeWarning raised as an exception at the exact test, and ruff check flags the discarded-coroutine call before review.
Verification¶
A clean codebase shows all of: (1) the repro under PYTHONASYNCIODEBUG=1 emits no was never awaited warnings; (2) len(asyncio.all_tasks()) returns to its baseline after each request rather than trending upward; (3) the CI suite passes with the warning-to-error filter active; and (4) ruff check reports no discarded-coroutine findings. In production, graph len(asyncio.all_tasks()) — a flat, request-correlated sawtooth is healthy; a monotonic climb is an active leak.
Pitfalls & edge cases¶
- The warning lies about location. Its timestamp is the GC finalization, not the leak. Never trust the surrounding log context; reconstruct the creation stack (steps 2–3).
- Globally suppressing
RuntimeWarningto quiet the logs hides real exceptions the orphan swallowed and memory it leaked. Filter the specific message, never the whole category. gather()of coroutines you forget to await leaves the parent unawaited even though the children would have run. Alwaysawaitthegather()/TaskGroupitself.- Fire-and-forget without a reference.
asyncio.create_task(...)whose return value is discarded can be GC'd mid-flight; keep a strong reference (a set you discard from in the done callback) — this is exactly what RuffRUF006enforces. - Sync/async boundary leaks. Calling an
async deffrom synchronous code (a thread-pool worker, a__del__, a callback) produces an orphan with no loop to schedule it; route throughasyncio.run_coroutine_threadsafe()orasyncio.to_thread()for the inverse.
Frequently Asked Questions¶
Why do unawaited coroutine warnings appear inconsistently across Python versions?
The warning fires during garbage-collection finalization, whose timing varies. Python 3.10+ tightened finalization checks, making warnings more deterministic and less dependent on GC cycles. Enabling PYTHONASYNCIODEBUG=1 standardizes detection by forcing the loop to track coroutine lifecycles and attach creation tracebacks.
Can I safely ignore the 'coroutine was never awaited' warning in fire-and-forget cases?
No. Fire-and-forget must explicitly schedule the coroutine with asyncio.create_task() and keep a strong reference to the returned task. Ignoring the warning leaves the coroutine untracked, risking silent memory leaks and swallowed exceptions that bypass your error handlers.
How do I trace an unawaited coroutine when the warning only shows the name?
Install a custom task factory that captures traceback.format_stack() at creation time and logs it via weakref.finalize if the task is collected unfinished. For raw coroutines, read cr_frame and use gc.get_referrers() to find what retains the reference. The warning's own timestamp reflects GC finalization, not the leak origin.
Does asyncio.gather() automatically await all passed coroutines?
gather() schedules and awaits the coroutines you pass it, but if you never await the gather() call itself, the resulting awaitable is left unawaited and the whole operation leaks. Always await the gather() or TaskGroup that owns the children.
Related¶
- Coroutine Design Patterns — up to the parent overview; its ownership rules prevent these leaks by construction.
- Structured Concurrency with asyncio.TaskGroup — give every task an owner so orphans cannot exist.
- Asyncio Fundamentals & Event Loop Architecture — how the scheduler registers and tracks tasks.