Debugging & Performance

Tracing "coroutine was never awaited" Warnings

RuntimeWarning: coroutine '...' was never awaited is one of the most misleading messages in asyncio: it fires when the orphaned coroutine is garbage-collected, so the traceback points at GC internals or an unrelated line, not the missing await. The coroutine object was created but never driven by the event loop, so whatever side effect you expected — a database write, an HTTP request, a cache invalidation — silently did not happen, and the program often keeps running with wrong state. By the time the warning surfaces in the logs, execution has moved on and the stack no longer contains the call that forgot to await. This guide turns that vague late warning into a hard error with a traceback that points straight at the coroutine's creation site, using tracemalloc and warning filters.

Prerequisites

  • Python 3.8+ (tracemalloc, the -X tracemalloc flag, and warnings filters).
  • For mocking causes: unittest.mock.AsyncMock (added in Python 3.8).
  • For the pytest path: any recent pytest with filterwarnings support.

Solution

Run the program with the warning promoted to an error and tracemalloc enabled so the message carries the allocation traceback:

Bash
# -W error::RuntimeWarning raises instead of logging late.
# -X tracemalloc attaches the traceback to where the coroutine was created.
python -W error::RuntimeWarning -X tracemalloc app.py

In code, the equivalent is explicit:

Python
import asyncio, tracemalloc, warnings

tracemalloc.start()                                # record allocation tracebacks
warnings.simplefilter("error", RuntimeWarning)     # missing await -> raised error

async def save(record: dict) -> None:
    await asyncio.sleep(0)                         # pretend to persist

async def main() -> None:
    save({"id": 1})         # BUG: no await -> coroutine created but never run
    await asyncio.sleep(0)  # yield so GC can collect the orphan and trigger the warning

asyncio.run(main())

With tracemalloc on, the raised error includes the allocation traceback that names the real culprit line:

Plain text
RuntimeWarning: coroutine 'save' was never awaited
Coroutine created at (most recent call last):
  File "app.py", line 10, in main
    save({"id": 1})

The Coroutine created at block is the payoff: without tracemalloc the warning has no such block, and the visible traceback is whatever code happened to trigger the garbage collection cycle. For the whole test suite, fail on it in pytest config so a single missing await breaks CI instead of leaking through:

TOML
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = ["error::RuntimeWarning"]
# run pytest with: pytest -W error::RuntimeWarning -p no:cacheprovider --tb=short

The fix is always one of three moves: add the await, schedule the coroutine explicitly with asyncio.create_task(save(...)) (and keep a reference to the returned task), or fold it into a concurrent batch with asyncio.gather(...).

The warning fires at garbage-collection time, which is why its location is almost never the location of the mistake.

Why the warning appears far from the bug A timeline showing the lifecycle of an un-awaited coroutine: the coroutine object is created at the call site, the reference is dropped when the enclosing function returns, garbage collection reclaims it at an arbitrary later point, and only then is the warning emitted with no reference to the original call site. Why the warning appears far from the bug created call without await the real mistake dropped function returns no reference left collected GC runs, later arbitrary moment warning printed here far from the cause
The warning is emitted by the coroutine finaliser, so its position in the output reflects when the collector ran, not when the call was made.

Why this works

A coroutine object created by calling an async def does nothing until it is driven by an await or scheduled on the loop — calling the function only builds a suspended state machine. If the only reference to that object is dropped, the garbage collector reclaims it, and CPython emits the RuntimeWarning from the coroutine's __del__ finalizer. That finalizer runs during a GC pass, which is why the default traceback is useless: it reflects wherever the interpreter happened to be collecting garbage, not where the coroutine was born. tracemalloc records a compact traceback at every allocation, so when the warning fires asyncio can reach back and attach the creation traceback — the exact call site that forgot the await. Promoting the warning to an error with the warnings filter makes the failure deterministic and CI-visible instead of a line buried in logs after the program already produced wrong results.

Lifecycle of a coroutine object and where tracemalloc helps Calling an async def creates a suspended coroutine object. If it is awaited or scheduled it is driven by the event loop and completes; if its reference is dropped the garbage collector reclaims it and __del__ emits RuntimeWarning: coroutine was never awaited. tracemalloc records the creation frame so the late warning can point back to the origin. Lifecycle of a coroutine object coroutine created async def called — suspended awaited dropped await / create_task(coro) driven by the event loop completes side effect runs reference dropped GC reclaims the orphan RuntimeWarning coroutine never awaited tracemalloc records the creation frame points back to origin
A coroutine created by calling an async def does nothing on its own. Awaited or scheduled, it is driven by the loop to completion; left unreferenced, it is garbage-collected and its __del__ emits the never-awaited warning. tracemalloc snapshots the creation frame so that late warning can name the exact line that forgot the await.

Edge cases and failure modes

  • Mocking an async method with Mock. A plain Mock returns a Mock, not an awaitable, so production code that awaits it breaks, while test code that calls it without awaiting leaks a coroutine. Use unittest.mock.AsyncMock for async methods — see when to use MagicMock vs Mock in Python.
  • Coroutine passed where a value is expected. if save(record): is always truthy because the coroutine object itself is truthy; the body branches on the object's existence, not on any awaited result, and the write never runs. Await first, then test the result.
  • Fire-and-forget without a reference. asyncio.create_task(coro) schedules the coroutine, but the loop only holds a weak reference to the task. If you keep no strong reference the task can be garbage-collected mid-flight and you get the same never-awaited warning for a coroutine you thought was running. Store the task in a set, discard it in the task's done callback, and await outstanding tasks at shutdown — the same dangling-task problem also produces the "Event loop is closed" RuntimeError at teardown.
  • Warning suppressed by a broad filter. A library or pytest config with filterwarnings = ["ignore"] hides it. Filters are evaluated last-to-first, so add an explicit error::RuntimeWarning rule after the broad ignore so it takes precedence for this category only.
  • Late GC hides the origin without tracemalloc. Without -X tracemalloc the warning has no creation traceback, and under an aggressive garbage collector the warning may not appear until well after the responsible frame has returned — always pair the two flags. The same allocation-traceback technique underpins memory profiling with tracemalloc.
  • Warning swallowed inside a task exception. If the orphan is created inside a task whose result is never retrieved, its __del__ warning can be overshadowed by a Task exception was never retrieved message. Fix the missing await and also await or add a done-callback to every task so neither warning is lost.

Turning the warning into a failing test

A warning that appears in the output of a green run will be ignored. The fix is to make it an error, which takes one line of configuration and immediately attributes the problem to the test that caused it.

TOML
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = [
  "error::RuntimeWarning",          # includes "coroutine ... was never awaited"
]

With -W error semantics the warning raises inside whichever test triggered the collection, which narrows the search to one test even though the call site may be elsewhere. Combine it with PYTHONASYNCIODEBUG=1, which adds the coroutine's creation traceback to the message, and the two together usually identify the exact line without any further investigation.

For the specific case of a mock returning a coroutine that nobody awaits, the root cause is nearly always a synchronous double standing in for an async function. unittest.mock.patch has detected async def targets and produced AsyncMock automatically since Python 3.8, so an explicit Mock() passed as new= is the usual culprit — dropping the explicit override lets autospec pick the right class:

Python
from unittest.mock import patch, AsyncMock

# Wrong: an explicit sync Mock silently returns a coroutine nobody awaits.
with patch("app.client.fetch", new=Mock()):
    ...

# Right: let patch detect the async def, or say AsyncMock explicitly.
with patch("app.client.fetch", new_callable=AsyncMock) as fetch:
    fetch.return_value = {"ok": True}

A last habit worth adopting: force a collection at the end of the test session so warnings surface deterministically rather than depending on when CPython happens to collect. A gc.collect() in a session-scoped teardown makes the warning appear on the run that caused it, which matters in CI where an intermittent warning is indistinguishable from no warning at all.

Where the missing await usually is A decision diagram covering the three usual sources of an un-awaited coroutine: a plain call that forgot the await keyword, a synchronous mock standing in for an async function, and a task created without keeping a reference. Where the missing await usually is What produced the coroutine object? a direct call add the await or wrap in create_task and keep the reference a mock use AsyncMock let patch autodetect never pass a sync Mock create_task hold the task loop keeps a weak ref store it in a set Turning RuntimeWarning into an error is what makes attribution automatic.
Three causes, three one-line fixes — the difficulty is only ever in attribution, not in the repair.

Frequently Asked Questions

Why does the never-awaited warning point at the wrong line? The RuntimeWarning fires when the unawaited coroutine is garbage collected, which can be far from where it was created. Enable tracemalloc so the warning includes the allocation traceback pointing at the coroutine's real origin.

How do I make a missing await fail the test suite? Run with -W error::RuntimeWarning, or set filterwarnings = error::RuntimeWarning in pytest config, so the warning is raised as an error. Combine it with -X tracemalloc to get the allocation traceback.

What are the most common causes of an unawaited coroutine? Calling an async function without await, passing a coroutine where a value is expected, forgetting to await asyncio.sleep or a client call, and mocking an async method with a plain Mock instead of AsyncMock. Does filterwarnings = error break third-party libraries? Sometimes, which is why the entry above targets RuntimeWarning rather than everything. Start narrow, add ignore entries for specific messages from dependencies you do not control, and keep the coroutine warning as an error — it is the one that reliably indicates a bug in your own code rather than a deprecation somewhere downstream.

← Back to Debugging Async Code and Event Loops