Skip to content

Creating Futures with loop.create_future

A bare asyncio.Future is the lowest-level awaitable in asyncio: a slot that will eventually hold a result or an exception, and a list of callbacks to run when it does. Application code rarely needs one, but every bridge to callback-based code does — a protocol waiting for a reply, a thread handing back a value, a library primitive that wakes waiters. The documentation's guidance is to create them with loop.create_future() rather than calling asyncio.Future(), because it lets alternative loops supply their own implementation. The more practical lessons are about binding: a future belongs to exactly one loop, awaiting it from another fails with got Future … attached to a different loop, and resolving it from a thread without call_soon_threadsafe left a waiter asleep for 2,001 ms in a test, until an unrelated timer happened to wake the loop — against 50 ms when the same result was delivered thread-safely.

Prerequisites

1. Create futures from the running loop

import asyncio


async def wait_for_reply(conn, request) -> bytes:
    loop = asyncio.get_running_loop()
    fut = loop.create_future()              # bound to the loop running this coroutine
    conn.on_reply(request.id, fut.set_result)
    conn.send(request)
    return await fut

create_future() is a loop method, so the loop decides which class to instantiate — the asyncio docs call it the preferred way because third-party loops can provide alternative Future implementations with better performance or instrumentation. On stock CPython the difference is noise either way: in a microbenchmark loop.create_future() took 112 ns and asyncio.Future() 90 ns, the gap being a method lookup.

The more important difference is explicitness. asyncio.Future() with no loop argument looks up the current event loop; outside a running loop on Python 3.14 that raised RuntimeError: There is no current event loop in thread 'MainThread'. Calling get_running_loop().create_future() states which loop the future belongs to at the call site, and fails loudly when there is none.

Verify: fut.get_loop() is asyncio.get_running_loop() inside the coroutine that awaits it.

The life of a bare Future A flow of 4 stages. The life of a bare Future loop.create_future() pending, bound to loop hand set_result away to protocol or thread resolved exactly once result, error or cancel awaiter resumes next loop iteration A future is a one-shot slot owned by one loop; everything else is about who resolves it and from where.

2. Keep each future on its own loop

A future records its loop when created. Awaiting it from a task on a different loop raises immediately:

holder = {}


async def make():
    holder["f"] = asyncio.get_running_loop().create_future()


async def use():
    await holder["f"]


asyncio.run(make())
asyncio.run(use())
# RuntimeError: Task <Task pending ...> got Future <Future pending> attached to a different loop

Each asyncio.run() creates a new loop, so this exact shape appears whenever a future (or anything holding one — an asyncio.Lock, a Queue, a client's connection pool) is created at import time or in one asyncio.run() and reused in another. Test suites that create a fresh loop per test hit it constantly with module-level fixtures; the fixture-scope fixes are in choosing event loop scopes in pytest-asyncio.

The fix is structural: create loop-bound objects inside the loop that uses them, typically in application startup or a lifespan handler, not at module import.

Verify: grep for module-level asyncio.Future(), asyncio.Lock() and asyncio.Queue(); each should move into code that runs on the loop.

3. Resolve from threads with call_soon_threadsafe

set_result is not thread-safe. Called from another thread, it updates the future's state and schedules the waiting task's wake-up with call_soon — which, from the wrong thread, does not wake the selector. The loop keeps sleeping until something else wakes it:

import threading
import time


async def main() -> None:
    loop = asyncio.get_running_loop()
    fut = loop.create_future()

    def worker() -> None:
        time.sleep(0.05)
        loop.call_soon_threadsafe(fut.set_result, 1)     # correct
        # fut.set_result(1)                              # wrong: waiter sleeps on

    threading.Thread(target=worker).start()
    async with asyncio.timeout(2):
        print(await fut)

Measured: the thread-safe version woke the waiter 50 ms after the thread started. The direct set_result version woke it after 2,001 ms — only because the 2-second timeout timer woke the loop, at which point the already-scheduled wake-up ran. In a service with plenty of other traffic the delay is short and random, which is why this bug survives testing. In debug mode, the unsafe call is detected as a non-thread-safe operation.

If the thread might race with cancellation, guard inside the callback, since set_result on a cancelled future raises:

def _set_if_pending(fut, value):
    if not fut.done():
        fut.set_result(value)

loop.call_soon_threadsafe(_set_if_pending, fut, value)

Verify: run the hand-off under PYTHONASYNCIODEBUG=1; no non-thread-safe operation errors, and wake-up latency matches the thread's work time.

Waking a waiter from a thread, 50 ms of thread work 2 horizontal bars comparing fut.set_result from the thread with the others. Waking a waiter from a thread, 50 ms of thread work fut.set_result from the thread 2,001 ms call_soon_threadsafe(set_result) 50 ms The unsafe version woke only when an unrelated 2 s timer woke the loop. The direct call does not wake the selector; how late the waiter wakes depends on unrelated traffic.

4. Prefer higher-level tools when they fit

A future is a single-use, single-value slot with no protection against double resolution. Several standard tools are futures with better ergonomics for common jobs:

Need Use Why not a bare future
wait for "something happened", many waiters asyncio.Event can be cleared and reused; set() is idempotent
run a coroutine and get its result asyncio.create_task a task is a future driven by a coroutine
result from a thread pool loop.run_in_executor wraps the thread hand-off for you
a stream of values asyncio.Queue futures resolve once
reply matched to a request future per request the right tool — see the correlator pattern

asyncio.Event.set() called twice is fine; fut.set_result() called twice raises InvalidStateError. When several code paths may complete the same thing — a response arriving and a timeout firing — the event's idempotence is often exactly what you want. The correlator pattern in correlating requests and responses with futures is the canonical place where a bare future is the right tool.

Verify: for each create_future() in your code, confirm that exactly one code path resolves it, or that every path checks done() first.

Is a bare future the right primitive? A decision on What needs to be delivered with 3 outcomes. Is a bare future the right primitive? What needs to be delivered? one value, one producer loop.create_future() resolve exactly once a signal, maybe twice asyncio.Event set() is idempotent several values asyncio.Queue futures resolve once Futures are the right tool at callback boundaries; elsewhere a higher-level primitive is usually safer.

5. Never drop a failed future silently

A future that is given an exception and then garbage-collected without anyone retrieving it logs Future exception was never retrieved with the exception's traceback. Verified on 3.14: setting an exception and deleting the only reference produced that log line immediately. It is a symptom — some code path resolved a future that nobody was waiting for — and the fix is either to stop creating it or to make whoever creates it also own its outcome:

def fail_pending(pending: dict[int, asyncio.Future], exc: BaseException) -> None:
    for fut in pending.values():
        if not fut.done():
            fut.set_exception(exc)
            fut.exception()      # mark retrieved only if no awaiter will ever exist

Marking an exception as retrieved should be a deliberate decision; doing it routinely hides real failures. Usually the right answer is that there should be an awaiter, and its absence is the bug.

Verify: a test run that exercises failure paths produces no "never retrieved" messages.

Verification

Futures are created and resolved correctly when:

  • Every future comes from get_running_loop().create_future() inside code running on that loop.
  • No loop-bound object is created at import time or shared across asyncio.run() calls.
  • Every resolution from another thread goes through call_soon_threadsafe and checks done().
  • No "never retrieved" or "different loop" errors appear in logs or tests.

Diagnostic Hook: add a debug-only wrapper that records where each future was created (traceback.extract_stack(limit=5)) and periodically logs futures that have been pending longer than a threshold. A future pending for minutes with no awaiter is a leaked bridge; one pending with an awaiter is a stuck producer — usually a thread that resolved it without waking the loop.

Pitfalls & edge cases

  • Resolving twice. set_result on a done future raises InvalidStateError; guard every path that can race.
  • Cancelling futures you did not create. Cancelling a future someone else will resolve makes their set_result raise.
  • Storing futures across reconnects. A future bound to an old loop or an old connection must be failed, not carried over.
  • Using asyncio.Future() in library code. It picks up whatever loop is current; prefer explicit loop.create_future().

Frequently Asked Questions

Should I use loop.create_future() or asyncio.Future()?

Use loop.create_future(). The asyncio documentation calls it the preferred way because the loop can supply its own Future implementation, and calling it on get_running_loop() makes the owning loop explicit.

Why do I get 'attached to a different loop'?

The future (or a lock, queue or client holding one) was created on one event loop and awaited from another, usually because it was created at import time or in a previous asyncio.run call. Create loop-bound objects inside the loop that uses them.

Can I call future.set_result from another thread?

Not directly. Use loop.call_soon_threadsafe(fut.set_result, value). A direct call does not wake the event loop, so the waiter may sleep until something unrelated wakes it — 2 seconds in one test.

When should I use an Event instead of a Future?

When the signal may be set more than once or by several code paths, or needs to be cleared and reused. Event.set is idempotent; Future.set_result raises if called twice.