skip to content

What goes wrong when an async test leaves asyncio tasks pending at teardown?

level: seniorimportance: should knowfreq 36%

answer

  1. The test ended, the work did not
  2. Destroyed but pending, only logged
  3. Lost exceptions and retained memory
  4. Leftovers bound to a dead loop
  5. Task group or cancel-and-await

basics

~10 s

The test finishes but its work does not: CPython logs 'Task was destroyed but it is pending', any exception the task raised is lost, and it keeps memory alive. Cancel and await every task.

solid answer

~50 s

A task created with `asyncio.create_task` and never awaited is still scheduled when the test's loop is torn down. Four consequences: `Task was destroyed but it is pending!` is *logged*, not raised, so CI stays green; a failure inside the task surfaces only as `Task exception was never retrieved` at collection time, attributed to no test; the loop keeps only a weak reference, so a fire-and-forget task can be collected mid-flight and silently stop; and its frame pins memory across the whole suite. Leftovers also poison later tests — an asyncio primitive or Future that actually waited under one loop raises `is bound to a different event loop` when reused under the next one, failing the innocent test. Fix structurally with `asyncio.TaskGroup` (3.11), fall back to cancel-then-await under `contextlib.suppress` in cleanup, and diff `asyncio.all_tasks()` in teardown so a leak fails its own test.

code

python · 24 lines
python
import asyncio
from contextlib import suppress


async def build_pick_list() -> None:
    await asyncio.sleep(3600)


async def test_body() -> None:
    asyncio.create_task(build_pick_list())    # started, then forgotten


async def main() -> None:
    before = asyncio.all_tasks()
    await test_body()
    leaked = asyncio.all_tasks() - before     # the teardown check
    for task in leaked:
        task.cancel()
        with suppress(asyncio.CancelledError):
            await task
    print("leaked:", sorted(t.get_name() for t in leaked))


asyncio.run(main())

go deeper

for a junior

Be ready to say that a task started with asyncio.create_task keeps running after the line that created it, and that the test must await or cancel it before finishing rather than just returning.

for a middle

Explain the mechanics: the loop holds only a weak reference to a task, an unretrieved exception is reported at collection time, and cancel must be followed by an await for the cancellation to be delivered.

for a senior

Show the diagnosis skill: recognise 'passes alone, fails in the suite' as loop-bound leftovers, diff asyncio.all_tasks() in teardown so a leak fails the test that caused it, and prefer TaskGroup so leaking is impossible.

for a principal

Own the convention: decide that every task in the codebase has a named owner and a lifetime — task groups at service boundaries, no module-scope async primitives — so leaks are prevented by structure rather than by review vigilance.

### What "pending at teardown" means An async test that calls `asyncio.create_task` starts work that outlives the statement that created it. If the test returns without awaiting or cancelling that task, the task is still scheduled when the test's event loop is torn down. Nothing about the green assertion result is affected — which is what makes the leak so easy to accumulate. Four distinct things go wrong. **The task is destroyed mid-flight.** When the loop closes and the task object is collected while unfinished, CPython logs `Task was destroyed but it is pending!`. It is written to asyncio's logger, not raised, so it scrolls past in CI output and fails nothing. **Its exception is swallowed.** If the leaked task raised, the exception lives in the task object and is only surfaced as `Task exception was never retrieved` when the object is garbage-collected — attributed to no test in particular, and often at interpreter shutdown. A genuine bug in the code under test can therefore hide behind a passing suite indefinitely. **It can vanish before it finishes.** The event loop holds only a *weak* reference to a task. If nothing else holds a strong reference, a fire-and-forget task can be collected mid-flight and simply stop, which produces "it works locally, it silently does nothing under load" behaviour. Keeping the task object in a local, an attribute, or a module-level set is not optional bookkeeping — it is what keeps the task alive. **It holds memory.** A leaked pick-list builder pins every object in its frame. Leak one per test across a few hundred tests and a 2.4 GB working set is an ordinary outcome; the suite gets slower, then the runner starts swapping, then a test that was never near the limit fails on timing. ### Cross-test contamination The subtler damage is to the *next* test. Give every test its own event loop — `asyncio.run` and `asyncio.Runner` (3.11) both do this — and a leaked task cannot literally run in the next test's loop. But objects it left behind can. An `asyncio` synchronisation primitive or `Future` created and actually waited on under one loop remembers that loop; reuse it under a second loop and you get `RuntimeError: ... is bound to a different event loop`, or `got Future ... attached to a different loop`. The failure is reported in the innocent test that touched the shared object, not in the test that created it, and the usual debugging path — running the failing test alone, where it passes — points in exactly the wrong direction. The design rule that follows: **build async primitives inside the test, never at module import time.** A module-level `asyncio.Queue()` or `asyncio.Lock()` shared by a test module is the classic source of "passes alone, fails in the suite". ### Making leaks fail loudly Two mechanisms, and a good answer names both. **Own your tasks structurally.** `asyncio.TaskGroup` (3.11) is an async context manager whose block cannot exit until every child task it created has finished; if one raises, the rest are cancelled and the errors surface as an `ExceptionGroup`. A test that uses a task group cannot leak by construction, which is strictly better than remembering to clean up. Where a task must outlive a block, the fallback is explicit: `task.cancel()`, then `await` it with `contextlib.suppress(asyncio.CancelledError)`, registered as a cleanup so it runs even when the test body raises. **Detect leaks in teardown.** Snapshot `asyncio.all_tasks()` at the start of the test and diff it at the end. Anything new and not done is a leak: cancel it, await it, and *fail the test*, naming the task via `Task.get_name()`. Turning the leak into a red test in the run that caused it is the whole point — a warning in the log is not a signal anybody acts on. Asyncio's debug mode, enabled with `asyncio.run(main(), debug=True)`, adds the creation traceback to these messages and is worth switching on for the suite. ### The interview answer in miniature A pending task at teardown means the test finished but the work did not: you get a `Task was destroyed but it is pending` log line, a possibly-lost exception, retained memory, and loop-bound leftovers that break a later test. Every task a test starts is the test's to finish — prefer `asyncio.TaskGroup` so it is structural, fall back to cancel-and-await in cleanup, and diff `asyncio.all_tasks()` in teardown so a leak fails the test that caused it rather than the one after it.

  • Why must a test keep a reference to the object returned by asyncio.create_task?
    The event loop holds only a weak reference to the task. If nothing else keeps a strong reference, the task can be garbage-collected while still pending and simply stop running, with no exception anywhere. Store it in a local, an attribute, or a module-level set that you discard from on completion.
  • A test passes alone but fails in the suite with 'bound to a different event loop'. What is happening?
    An asyncio synchronisation primitive or Future was created at import time or in an earlier test and actually waited under that test's loop, binding it. The next test runs a fresh loop, touches the same object, and gets a RuntimeError — reported in the innocent test. Build async primitives inside the test, never at module scope.
  • How do you turn a leaked task into a red test rather than a log line nobody reads?
    Snapshot `asyncio.all_tasks()` at the start of the test and diff it in teardown. Anything new and unfinished is a leak: cancel it, await it under `contextlib.suppress(asyncio.CancelledError)`, and fail the test naming the task via `Task.get_name()`. Enabling asyncio's debug mode adds the task's creation traceback to the message.

Leaving a task pending is walking out of the warehouse while a picker is still working the aisles: the shift report says done, and whatever they were carrying is found by the next shift.

saying these in an interview costs you the question

  • Ignores 'Task was destroyed but it is pending' output
  • Assumes closing the event loop cancels and awaits tasks
  • Fires create_task and keeps no reference to the task
  • Shares asyncio primitives at module scope across tests
  • Cancels leaked tasks but never awaits them
  • Believes a leaked task cannot affect a later test

context