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 tracemallocflag, andwarningsfilters). - For mocking causes:
unittest.mock.AsyncMock(added in Python 3.8). - For the pytest path: any recent
pytestwithfilterwarningssupport.
Solution
Run the program with the warning promoted to an error and tracemalloc enabled so the message carries the allocation traceback:
# -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:
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:
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:
# 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 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.
Edge cases and failure modes
- Mocking an async method with
Mock. A plainMockreturns aMock, not an awaitable, so production code thatawaits it breaks, while test code that calls it without awaiting leaks a coroutine. Useunittest.mock.AsyncMockfor 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
pytestconfig withfilterwarnings = ["ignore"]hides it. Filters are evaluated last-to-first, so add an expliciterror::RuntimeWarningrule after the broad ignore so it takes precedence for this category only. - Late GC hides the origin without tracemalloc. Without
-X tracemallocthe 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 aTask exception was never retrievedmessage. Fix the missingawaitand 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.
# 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:
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.
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.
Related
- Debugging "Event loop is closed" RuntimeError — the other classic teardown-timing failure in asyncio.
- Memory profiling with tracemalloc — the allocation-traceback machinery that makes these warnings actionable.
- When to use MagicMock vs Mock in Python — pick
AsyncMockso mocked async methods stay awaitable. - Interactive debugging with pdb and ipdb — drop a breakpoint at the creation site once tracemalloc has named it.
← Back to Debugging Async Code and Event Loops