Skip to content

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.TaskGroup and 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.
Why the warning fires far from the bug A coroutine is created at a call site but never scheduled, so it bypasses the loop registry, lingers in memory, and the RuntimeWarning only fires during a later, unrelated garbage-collection cycle. The warning fires far from the bug Call site coro = fn() (no await) Loop registry never registered Lingers in RAM holds closure + args GC finalize RuntimeWarning t = 0 bug happens here t = later warning prints here the gap you must reconstruct: capture the stack at creation, not at finalization

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.

import asyncio
import gc


async def sync_user(uid: int) -> str:
    await asyncio.sleep(0.01)
    return f"user-{uid}"


async def main() -> None:
    sync_user(42)  # BUG: called, never awaited -> orphan
    gc.collect()   # force finalization so the warning prints now


if __name__ == "__main__":
    # Run with: PYTHONASYNCIODEBUG=1 python repro.py
    asyncio.run(main())

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.

import asyncio
import logging
import traceback
import weakref
from typing import Any, Coroutine

logger = logging.getLogger("asyncio.leak_detector")


def leak_aware_task_factory(loop, coro, *, name=None, context=None) -> asyncio.Task:
    creation_stack = "".join(traceback.format_stack())
    task = asyncio.Task(coro, loop=loop, name=name, context=context)

    def _on_gc(ref: "weakref.ref[asyncio.Task[Any]]") -> None:
        t = ref()
        if t is not None and not t.done():
            logger.critical(
                "Task %s GC'd before completion.\nCreated at:\n%s",
                t.get_name(), creation_stack,
            )

    weakref.finalize(task, _on_gc, weakref.ref(task))
    return task


async def main() -> None:
    asyncio.get_running_loop().set_task_factory(leak_aware_task_factory)
    asyncio.create_task(asyncio.sleep(1), name="orphan")  # never awaited
    await asyncio.sleep(0)  # let it schedule, then leak on exit

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.

import asyncio
import gc


async def leaky() -> None:
    await asyncio.sleep(0.1)


async def main() -> None:
    container = []
    coro = leaky()
    container.append(coro)  # the closure/list keeps it alive

    frame = coro.cr_frame
    print("coro defined in:", frame.f_code.co_name, "@", frame.f_lineno)
    # Who is holding the reference? -> points at `container`
    for ref in gc.get_referrers(coro):
        if isinstance(ref, list):
            print("retained by a list of len", len(ref))

    container.clear()  # drop it so it can be reclaimed cleanly


asyncio.run(main())

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.

import asyncio
import contextlib
import logging
from typing import AsyncIterator

logger = logging.getLogger(__name__)


@contextlib.asynccontextmanager
async def audit_task_scope() -> AsyncIterator[None]:
    loop = asyncio.get_running_loop()
    before = set(asyncio.all_tasks(loop))
    try:
        yield
    finally:
        leaked = set(asyncio.all_tasks(loop)) - before - {asyncio.current_task()}
        for t in leaked:
            logger.warning("Leaked task in scope: %s", t.get_name())
            t.cancel()
        if leaked:
            await asyncio.gather(*leaked, return_exceptions=True)


async def main() -> None:
    async with audit_task_scope():
        asyncio.create_task(asyncio.sleep(5), name="forgotten")  # leak
        await asyncio.sleep(0)

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.

1
2
3
4
5
6
7
8
9
import warnings
import pytest


@pytest.fixture(autouse=True)
def fail_on_unawaited():
    with warnings.catch_warnings():
        warnings.filterwarnings("error", message=r"coroutine .* was never awaited")
        yield

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 RuntimeWarning to 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. Always await the gather()/TaskGroup itself.
  • 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 Ruff RUF006 enforces.
  • Sync/async boundary leaks. Calling an async def from synchronous code (a thread-pool worker, a __del__, a callback) produces an orphan with no loop to schedule it; route through asyncio.run_coroutine_threadsafe() or asyncio.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.