skip to content

What distinguishes an asyncio.Task from a plain asyncio.Future?

level: middleimportance: should knowfreq 44%

answer

  1. One of them is a subclass of the other
  2. One has a body, one does not
  3. Somebody else has to fill the slot
  4. The bridge from callbacks into await

basics

~20 s

asyncio.Task is a subclass of asyncio.Future. A Future is an empty result slot someone else fills with set_result or set_exception; a Task additionally owns a coroutine and drives it step by step on the event loop until it completes.

solid answer

~50 s

`asyncio.Future` is a low-level placeholder for a value that is not there yet: it is awaitable, it holds exactly one outcome, and **something external** must fill it by calling `set_result()` or `set_exception()`. It has no code of its own. `asyncio.Task` subclasses `Future` and adds the driver: it wraps a coroutine and schedules a step of that coroutine each time it can make progress, then sets its own result when the coroutine returns. So every Task is a Future, but a Future is not necessarily running anything. In practice you create Tasks constantly with `asyncio.create_task` and create bare Futures rarely — mainly to bridge callback-style code into `await`, by making a future with the running loop's factory and having the callback call `set_result`. Note that `asyncio.Future` is a different class from `concurrent.futures.Future` and is not thread-safe.

code

python · 15 lines
python
import asyncio


def start_legacy_fetch(on_done):
    on_done("depot-7 snapshot")


async def main():
    future = asyncio.get_running_loop().create_future()
    start_legacy_fetch(future.set_result)
    print("awaited value:", await future)
    print("has a coroutine of its own:", hasattr(future, "get_coro"))


asyncio.run(main())

go deeper

for a junior

Remember the one-line relationship: Task is a Future that runs a coroutine. You are unlikely to create a bare Future yourself, but recognise one when a library hands it to you.

for a middle

Explain the result protocol both share — done, result, exception, add_done_callback — and what only a Task has: a coroutine it steps on the loop. Know that a Future stays pending until external code sets it.

for a senior

Show where a bare Future is the right tool: adapting a callback-driven or transport-level API into await, and doing so through the running loop's factory. Be clear that these futures are single-loop and not thread-safe.

for a principal

Own the boundary design: where your codebase converts callback and executor worlds into awaitables, who is allowed to hold a raw Future, and how those seams avoid leaking cross-thread assumptions into loop-bound objects.

### The class relationship `issubclass(asyncio.Task, asyncio.Future)` is `True`, and that single fact organises the whole answer. Everything a Future can do, a Task can do; a Task adds one capability — it drives a coroutine. ### What a Future is An `asyncio.Future` is a one-shot result slot with three states: pending, finished (with a result or an exception), or cancelled. Its surface is small: * `set_result(value)` / `set_exception(exc)` — fill the slot. Calling either twice, or calling them on a finished future, raises `asyncio.InvalidStateError`. * `result()` / `exception()` — read the outcome; `result()` re-raises a stored exception, and both raise `InvalidStateError` if the future is not done. * `done()`, `cancelled()`, `cancel()`, `add_done_callback(fn)`. * `__await__` — awaiting a pending future suspends the awaiting coroutine and registers it to be woken when the slot is filled. Crucially there is **no code inside a Future**. It will stay pending forever unless some other party fills it. That other party is usually a callback: a protocol receiving bytes, a signal handler, a timer, or an adapter around a callback-based library. This is the Future's real job in asyncio — it is the seam between the callback world and the `await` world. ```python future = asyncio.get_running_loop().create_future() start_legacy_fetch(future.set_result) # callback fills the slot value = await future # await world sees a plain value ``` Futures are bound to one event loop and are **not thread-safe**; touching one from another thread is a bug, and asyncio provides a dedicated thread-safe scheduling helper for that case. ### What a Task adds A Task is constructed around a coroutine object. On creation it asks the loop to run its first step. Each step resumes the coroutine until it yields at an `await`; the Task then arranges to be woken when whatever it is waiting on completes, and schedules the next step. When the coroutine returns, the Task calls `set_result` **on itself**; when it raises, `set_exception` on itself. From the outside it looks exactly like a Future that fills itself in. That extra machinery gives Tasks members a bare Future does not have — most visibly `get_coro()` for the coroutine it is driving, plus name and stack introspection — and it changes what `cancel()` means. Cancelling a bare Future just marks the slot cancelled and wakes its waiters. Cancelling a Task requests that `CancelledError` be delivered *into* the running coroutine at its current suspension point, so the coroutine's `finally` blocks run. Same method name, meaningfully different act. ### Which one do you create? Almost always a Task, via `asyncio.create_task`. Reasons to make a bare Future are narrow but real: * Adapting a callback API (as above) or a completion notification from a transport. * Building a one-shot signal between coroutines where a full task would be overkill — although the synchronisation primitives usually express that better. * Writing a library that returns "something awaitable" whose value it will supply later. When you do make one, use the running loop's `create_future()` factory rather than constructing the class directly: a loop implementation may supply an optimised subclass. ### How the two meet at runtime The relationship is not just conceptual — it is the mechanism the loop actually uses. When a Task's coroutine awaits something that is not yet ready, what it ultimately awaits is a Future: a sleep registers a timer that will fill one, a socket read registers a reader callback that will fill one, a lock registers a waiter that will fill one. The Task attaches a done-callback to that Future which reschedules its own next step, then stops. So a Task is a small state machine parked on a Future, woken by that Future being completed, and completing a Future of its own at the end. Once you see that, `await` stops looking magical: every suspension in an asyncio program bottoms out in some Future that some callback will fill. This also explains a rule that otherwise looks arbitrary — why filling a Future from another thread is forbidden. The done-callbacks it fires are scheduled on one specific loop, and asyncio provides a separate thread-safe scheduling entry point precisely so cross-thread code has a legal way in. ### The name collision worth flagging `asyncio.Future` and `concurrent.futures.Future` are different classes with different assumptions. The executor one is thread-safe and has a blocking `result(timeout)`; the asyncio one is single-loop, non-blocking, and its `result()` never waits — it either gives you the outcome or raises `InvalidStateError`. Anything crossing between the two worlds needs an explicit adapter; that boundary is its own topic. ### How to say it in an interview "A Future is a result slot somebody else fills. A Task is a Future that fills itself by running a coroutine on the loop." Then add the two consequences that matter: awaiting either suspends you until it is done, but only a Task has a body — so only a Task can be cancelled *into*, and only a Task makes progress on its own.

  • How do you read a Task's outcome without awaiting it?
    Check `task.done()` first, then call `task.result()` for the value (it re-raises the coroutine's exception) or `task.exception()` to get the exception object instead. Calling either on a task that is still pending raises `asyncio.InvalidStateError`, and on a cancelled task `result()` raises `CancelledError`. This is the pattern for harvesting outcomes from a set of handles you already hold.
  • Why create a Future with the running loop's create_future() rather than calling asyncio.Future() directly?
    Because the loop is allowed to return an optimised or instrumented Future implementation of its own. Going through the loop's factory keeps your code working on alternative loop implementations and matches what asyncio itself does internally. Constructing the class directly also risks binding it to the wrong loop when more than one exists.
  • Is asyncio.Future the same thing as concurrent.futures.Future?
    No — distinct classes with different contracts. The executor one is thread-safe and its `result()` blocks the calling thread with an optional timeout; the asyncio one belongs to a single event loop, is not thread-safe, and its `result()` never blocks — it returns the outcome or raises InvalidStateError. Crossing between them requires an explicit adapter rather than duck typing.

A Future is a numbered pigeonhole: awaiting it means standing by the box until someone drops something in. A Task is a pigeonhole with a courier permanently assigned to fill it.

saying these in an interview costs you the question

  • Says Future and Task are unrelated classes
  • Thinks a bare Future runs a coroutine of its own
  • Expects asyncio.Future.result() to block until ready
  • Confuses asyncio.Future with concurrent.futures.Future
  • Believes cancelling a Future and a Task do the same thing

context