skip to content

When does asyncio log 'Task exception was never retrieved', and why so late?

level: middleimportance: should knowfreq 50%

answer

  1. Nobody is waiting to receive it
  2. Reported at destruction, not at failure
  3. The finalizer calls the loop's exception handler
  4. result or exception marks it consumed
  5. Held forever means reported never

basics

~20 s

Nothing is reported when the task raises: the exception is stored on the Task and flagged unconsumed. Only when that Task object is finalized without anyone calling result() or exception() does its finalizer log the message.

solid answer

~50 s

A failing task does not propagate anywhere on its own — it stores the exception, flags it as not yet retrieved, and the loop carries on. The report is produced by the task object's finalizer: if `Task.result()` or `Task.exception()` was never called, `__del__` builds a context dictionary and passes it to the event loop's exception handler, which by default logs `Task exception was never retrieved` at ERROR on the `asyncio` logger together with the traceback. Under CPython reference counting that often happens moments after the last reference goes away — but a reference cycle delays it to the next collector pass, and a strong reference you keep forever (an owning set you never drain) suppresses it entirely. That is why background failures appear at random points in a log, or never. Retrieve deliberately: await the task, or use `Task.add_done_callback()` with a handler that calls `Task.exception()`.

code

python · 11 lines
python
import asyncio

async def boom():
    raise RuntimeError("log file left open")

async def main():
    asyncio.create_task(boom())
    await asyncio.sleep(0.05)
    print("main never saw the failure")

asyncio.run(main())

go deeper

for a junior

Know that a task that raises does not crash your program or interrupt the caller: the exception sits on the task until someone asks for it. Recognise the Task exception was never retrieved log line as the sign that nobody did.

for a middle

Explain the mechanism end to end: the exception is stored with an unconsumed flag, the task's finalizer passes a context to the loop's exception handler, Task.result() or Task.exception() clears the flag, and garbage-collection timing decides when — or whether — the line ever appears.

for a senior

Show the production consequence: errors clustered at collection time rather than failure time, a signal on the asyncio logger that most log configs drop, and no application context. Replace it with an explicit outcome handler that names the task and decides whether the wider job should fail.

for a principal

Frame it as an observability policy. Decide organisation-wide whether unsupervised background failures are permitted at all, mandate a supervised spawn helper that records outcomes as metrics rather than log lines, and make the default asyncio message an alert that a rule was bypassed.

### What 'retrieved' means When a coroutine driven by an `asyncio.Task` raises, the task completes in the *exception* state: the exception instance is stored on the task and an internal flag records that nobody has consumed it. Consuming means calling `Task.result()` — which re-raises it — or `Task.exception()` — which returns it. Awaiting the task calls `result()` for you. Any of these clears the flag. Nothing about a raise inside a task pushes it into your code: the loop has nowhere to deliver it, because the whole point of a task is that no caller is waiting. ### The finalizer path The report comes from the task object's `__del__`. If the unconsumed flag is still set, the finalizer assembles a context dictionary containing the message `Task exception was never retrieved`, the exception and the task, and passes it to the event loop's exception handler. The default handler logs at ERROR level on the `asyncio` logger, including the traceback, and — if the loop is in debug mode — the traceback of where the task was created, which is usually the more useful of the two. So the report is a *finalization* event, not a *failure* event, and that single fact explains everything odd about it. ### Why the timing is unpredictable CPython frees objects by reference counting, so in simple cases the task is destroyed as soon as the last reference drops — often immediately after the loop step that finished it, making the log line look prompt. But three things routinely push it around: * **Reference cycles.** A stored exception carries a traceback, which carries frames, which reference locals — cycles are the norm here. Then destruction waits for a generational collector pass, which happens on allocation pressure at an arbitrary later time. * **A reference you kept.** Fixing the collected-task problem by putting tasks into a long-lived set works — and if you never drain that set, the tasks are never finalized and the failures are *never* reported. The two pitfalls of fire-and-forget work pull against each other, and this is where the pair meets. * **Process exit.** If the interpreter shuts down before the object dies, you may get nothing at all, or a truncated report during finalization. The symptom in an incident review is a stack of identical errors clustered at a moment that has nothing to do with when the work actually failed. ### It is not the same as the never-awaited warning Keep the two apart. A `RuntimeWarning` about a coroutine that was never awaited means a coroutine object was created and never driven — the body never ran. `Task exception was never retrieved` means the opposite: the body ran, it failed, and nobody looked at the outcome. Confusing them sends you looking in the wrong place. ### Retrieving deliberately The cheapest correct pattern is a done callback that inspects the outcome: ```python def report(task: asyncio.Task) -> None: if task.cancelled(): return exc = task.exception() if exc is not None: log.error("task %s failed", task.get_name(), exc_info=exc) ``` Two details matter. The `Task.cancelled()` guard is required because calling `Task.exception()` on a cancelled task raises `asyncio.CancelledError` rather than returning it. And the callback only ever runs on a finished task, so `asyncio.InvalidStateError` — which `exception()` raises while a task is still pending — cannot occur here. Calling `exception()` marks the failure consumed, so the finalizer stays quiet and *your* log line, with your task name and your context, is the record instead. ### Why the default line is not good enough in production Even when it does fire, `Task exception was never retrieved` is a poor signal. It arrives on the `asyncio` logger, which many applications never configure or deliberately quiet; it arrives at finalization time rather than failure time, so it correlates with nothing; and it carries no application context — not which batch, which tenant, which file. Treat it as a smell that tells you a background task is unsupervised, and replace it with an explicit outcome handler that logs, counts, and decides whether the failure should abort the wider job. ### Interview framing The answer an interviewer is listening for has three beats: the exception is stored, not raised; the message comes from the finalizer through the loop's exception handler; and the timing therefore depends on garbage collection — up to and including never, if something keeps the task alive. A candidate who says the loop reports task failures as they happen has not yet operated a fire-and-forget service.

  • Why must a done callback check Task.cancelled() before calling Task.exception()?
    Because `Task.exception()` does not return the cancellation — on a cancelled task it raises `asyncio.CancelledError` out of your callback, which then surfaces through the loop's exception handler as noise. Guarding with `Task.cancelled()` and returning early keeps ordinary shutdown cancellations out of your error path while still catching genuine failures.
  • If I keep every task in a set forever, do I still get the never-retrieved message?
    No, and that is the trap. The message comes from the finalizer, so a task you hold alive is never finalized and never reported; the failure is invisible and the set grows for the life of the process. Holding a strong reference fixes early collection but must be paired with draining the set and retrieving each outcome.
  • How does this differ from a coroutine that was never awaited?
    Opposite problems. The never-awaited RuntimeWarning means a coroutine object was created and never driven, so its body never executed. `Task exception was never retrieved` means the body ran to a failure and nobody consumed the result. One is work that never started; the other is work that failed unnoticed.

It is a letter of complaint that is only opened when the desk it sits on is finally cleared out — and if the desk is never cleared, nobody ever reads it.

saying these in an interview costs you the question

  • Says the event loop raises task exceptions into the caller
  • Claims the message appears the moment the task fails
  • Thinks a try/except around create_task catches the failure
  • Believes holding every task in a set makes failures visible
  • Confuses it with the coroutine-was-never-awaited warning
  • Calls Task.exception() without guarding for cancellation

context