asyncio.create_task starts a coroutine running in the background and hands you a Task object. What it doesn’t hand you is a plan for what happens when that task fails, takes too long, or is still running when the program wants to exit. Those three questions make up what people mean by task ownership: some piece of code has to await the task, decide its lifetime, and see its exception. When nobody does, errors vanish into a log line and shutdowns hang. That’s the territory interview questions about tasks and cancellation tend to cover.
Fire and forget, and where the exception goes
Here’s a signup handler that sends a welcome email in the background so the HTTP response isn’t delayed:
import asyncio
async def send_email(user):
await asyncio.sleep(0.05)
raise ConnectionError(f"SMTP refused for {user}")
async def signup(user):
asyncio.create_task(send_email(user)) # fire and forget
return {"user": user, "status": "created"}
async def main():
print(await signup("ana"))
await asyncio.sleep(0.1)
print("main finished")
asyncio.run(main())Output on Python 3.14 (paths trimmed):
{'user': 'ana', 'status': 'created'}
Task exception was never retrieved
future: <Task finished name='Task-2' coro=<send_email() done, defined at at1.py:3> exception=ConnectionError('SMTP refused for ana')>
Traceback (most recent call last):
File "at1.py", line 5, in send_email
raise ConnectionError(f"SMTP refused for {user}")
ConnectionError: SMTP refused for ana
main finishedThe user got a success response, and the failure shows up only as “Task exception was never retrieved”, which asyncio logs when the task object is garbage collected without anyone having called .result(), .exception() or awaited it. Nothing retries, no metric is incremented, and with a different logging configuration you might not see it at all. In a busy service the timing of that log line depends on the garbage collector, so it can appear far from the code that caused it.
There’s a second, quieter problem. The event loop keeps only a weak reference to tasks. The asyncio docs say this directly: save a reference to the result of create_task, because a task that isn’t referenced anywhere else can be garbage collected before it finishes. In practice the coroutine’s own frame often keeps it alive, but “often” isn’t a property you want behind your email sending.
Giving background tasks an owner
If a task really must outlive the request that started it, give it an explicit owner: a set that holds strong references and a done-callback that reports failures.
import asyncio
background = set()
def spawn(coro):
task = asyncio.create_task(coro)
background.add(task)
task.add_done_callback(background.discard)
task.add_done_callback(report)
return task
def report(task):
if not task.cancelled() and task.exception() is not None:
print("background task failed:", repr(task.exception()))
async def send_email(user):
await asyncio.sleep(0.05)
raise ConnectionError(f"SMTP refused for {user}")
async def main():
spawn(send_email("ana"))
await asyncio.sleep(0.1)
print("pending:", len(background))
asyncio.run(main())background task failed: ConnectionError('SMTP refused for ana')
pending: 0The set keeps each task alive until it finishes, the first callback removes it, and the second turns a silent failure into something you can log, count or alert on. Calling task.exception() also marks the exception as retrieved, so the “never retrieved” warning goes away. At shutdown you can cancel everything in background and await asyncio.gather(*background, return_exceptions=True).
That said, for anything that matters (emails, payments, webhooks), an in-process task is the wrong tool. It dies with the process during a deploy. A durable queue with retries is the honest answer, and interviewers are often happy to hear you say so.
Structured concurrency with TaskGroup
When tasks belong to a single operation, Python 3.11’s asyncio.TaskGroup makes ownership automatic. The async with block doesn’t exit until every child finishes, and if one child raises, the group cancels the rest.
import asyncio
async def fetch(name, delay, fail=False):
try:
await asyncio.sleep(delay)
if fail:
raise ValueError(f"{name} failed")
print(f"{name} done")
return name
except asyncio.CancelledError:
print(f"{name} cancelled")
raise
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch("prices", 0.3))
tg.create_task(fetch("stock", 0.1, fail=True))
tg.create_task(fetch("reviews", 0.5))
except* ValueError as eg:
print("caught:", eg.exceptions)
asyncio.run(main())prices cancelled
reviews cancelled
caught: (ValueError('stock failed'),)stock failed at 100 ms, so prices and reviews were cancelled instead of running on for work nobody would use. The failure arrives as an ExceptionGroup, which is why the example uses except*. A plain except ValueError won’t match it, a detail candidates often miss.
Compare this with asyncio.gather. By default, if one awaitable raises, gather propagates that exception to you but does not cancel the others; they keep running unowned. TaskGroup is the better default when the children are parts of one result.
Cancellation is a request, not a kill
task.cancel() doesn’t stop a task immediately. It schedules a CancelledError to be raised at the task’s next await. The coroutine can catch it, which is fine for cleanup, but it must re-raise. Here’s what happens when it doesn’t:
import asyncio
async def worker():
while True:
try:
await asyncio.sleep(0.2)
except asyncio.CancelledError:
print("worker: ignoring cancel") # swallowed, loop continues
async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(0.1)
t.cancel()
await asyncio.sleep(0.5)
print("done?", t.done(), "| cancelling() =", t.cancelling())
asyncio.run(main())worker: ignoring cancel
done? False | cancelling() = 1
worker: ignoring cancelThe task kept running after the cancel. Then, when main returned, asyncio.run tried to cancel the leftover task and wait for it. The worker swallowed that cancel too, so asyncio.run never returned and the process had to be killed. The same thing happens to a server’s graceful shutdown or a TaskGroup waiting for a child that won’t die.
Broad handlers cause this by accident. CancelledError inherits from BaseException (since 3.8), so except Exception doesn’t catch it, but except BaseException or a bare except: does. The rule: catch it only to release resources, then raise. If you genuinely need to suppress a cancellation, call task.uncancel() so asyncio.timeout and TaskGroup can still tell what happened.
This matters more than it first appears, because cancellation is the mechanism behind other features. asyncio.timeout(5) works by cancelling the current task when the deadline passes and converting the resulting CancelledError into TimeoutError on the way out. A worker that swallows cancellation therefore also defeats every timeout wrapped around it. The same goes for asyncio.shield: it protects the inner awaitable from the outer cancel, but the inner task still needs an owner, or you’ve recreated the fire-and-forget problem with an extra step.
Interview exercise: the crawler that won’t stop
A crawler starts 50 tasks with create_task, each fetching pages in a while True loop wrapped in try/except BaseException: log and continue. On Ctrl+C the process never exits, and occasionally a task’s error never appears in the logs. Explain both symptoms and the fix.
Answer
The hang comes from the except BaseException handler. Ctrl+C makes asyncio.run cancel the main task and then all remaining tasks, which raises CancelledError inside each worker. The handler catches it, logs it, and loops again, so the tasks never finish and asyncio.run waits forever. The missing errors come from ownership: the 50 tasks are created and never awaited, so a task that crashes in a way the handler doesn’t catch (or before it enters the loop) only produces “Task exception was never retrieved” whenever the object gets collected. The fix is to run the workers inside an async with asyncio.TaskGroup() so the group owns them and surfaces their failures, narrow the handler to the errors you expect from a fetch (except (httpx.HTTPError, TimeoutError)), and if you need cleanup on cancellation, use try/finally or catch CancelledError and re-raise it.