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¶
- Python 3.11+, stdlib only.
- Future states and callbacks, from Future Objects & Callbacks.
- Thread hand-off, from resolving futures from other threads safely.
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.
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.
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.
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_threadsafeand checksdone(). - 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_resulton a done future raisesInvalidStateError; guard every path that can race. - Cancelling futures you did not create. Cancelling a future someone else will resolve makes their
set_resultraise. - 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 explicitloop.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.
Related¶
- Future Objects & Callbacks — up to the topic overview.
- Avoiding InvalidStateError when setting future results — the double-resolution problem in depth.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.