Understanding asyncio.create_task vs asyncio.ensure_future¶
You have a coroutine and you want it running concurrently on the loop. Two functions appear to do the job — asyncio.create_task() and asyncio.ensure_future() — and code review keeps surfacing both. The symptom that brings most teams here is concrete: a function annotated -> asyncio.Task is fed the result of ensure_future(), mypy narrows it to Future, the .get_name() call downstream loses its type, and a debugging session disappears into why a task can't be found by name in all_tasks(). This guide settles which to call, explains the loop-binding and return-type differences that drive that decision, and gives a verifiable migration path.
Prerequisites¶
- Python 3.11+.
create_task()has been the recommended API since 3.7; the examples below also useasyncio.timeout()and exception groups from 3.11. - A running event loop.
create_task()must be called from inside a coroutine (it requiresasyncio.get_running_loop()to succeed). All snippets run underasyncio.run(). - Familiarity with the lifecycle. This is a detail page under Task Scheduling & Lifecycle; skim that overview for how a task moves through the ready queue. For the full loop model — selector, timers, executors — see Asyncio Fundamentals & Event Loop Architecture.
The short answer: call create_task() when you have a coroutine, which is almost always. Reach for ensure_future() only when the input might already be a Future and you need to normalize it. The steps below show how to confirm that for your own code.
1. Confirm the input is a coroutine, not a Future¶
The entire decision turns on input type. ensure_future() exists to accept anything awaitable and return a Future; if you already know you hold a coroutine, that flexibility is dead weight. Inspect the value at the call site before choosing.
Verify: inspect.iscoroutine() returns True and asyncio.isfuture() returns False. When that holds, create_task() is the right call. If isfuture() were True, you would not wrap it again — you would await it or pass it through ensure_future().
2. Compare the return types and what they expose¶
create_task() returns a concrete asyncio.Task; ensure_future() is typed to return a Future. Task is a subclass, so it carries task-only methods (get_name, set_name, get_coro, get_stack) that Future does not. This is what static analysis keys on.
Verify: t.get_name() returns "named-task". Run mypy --strict over the module: a .get_name() call on the ensure_future result is flagged, while the create_task result type-checks cleanly. That difference — not runtime behavior — is the practical reason to standardize.
3. Inspect where each lands in the ready queue¶
At runtime both end up as a Task scheduled via loop.call_soon(), but ensure_future() first runs branching to decide whether to wrap. You can observe that both register immediately by snapshotting asyncio.all_tasks().
Verify: the task count rises by 2 immediately after the calls — both are scheduled the moment they are created, not when awaited. Both are Task instances at runtime; the divergence is purely the declared return type and the wrapping branch ensure_future executes first.
4. Confirm exception and cancellation behavior is identical¶
Because both yield a Task, exception propagation and cancellation work the same way. Validating this removes any fear that migrating changes error handling.
Verify: both call sites print "cancelled cleanly" — cancellation injects CancelledError at the next await identically. The RuntimeError never surfaces because cancellation wins the race; if you removed task.cancel(), both would raise RuntimeError on await task. Identical semantics confirm the swap is behavior-preserving.
5. Migrate ensure_future call sites mechanically¶
With the equivalence confirmed, replace coroutine-wrapping ensure_future() calls. The safe transformation only applies when the single argument is a coroutine and no loop= is passed.
Verify: the first line is rewritten to create_task; the second, which passes loop=, is deliberately left alone because it may be normalizing an existing Future. Run your test suite under python -W error::DeprecationWarning -m pytest afterward — coroutine-wrapping ensure_future calls in 3.10+ paths surface as errors, confirming none were missed.
Verification¶
After migrating, the codebase should satisfy all of the following:
- No coroutine-wrapping
ensure_future:grep -rn "ensure_future" src/returns only sites that pass an existingFuture/awaitable (typically withloop=or normalizing mixed inputs). - Clean strict typing:
mypy --strict src/raises noFuture-vs-Taskerrors, and.get_name()/.set_name()calls type-check. - No deprecation noise:
python -W error::DeprecationWarning -m pytestpasses. - Identical runtime behavior: the existing test suite is green — cancellation, exception propagation, and result collection are unchanged because both APIs produce a
Task.
Pitfalls & edge cases¶
create_task()needs a running loop. Calling it at module import time or beforeasyncio.run()raisesRuntimeError: no running event loop.ensure_future()can fall back toget_event_loop()in older code, which is exactly the implicit-loop behavior to avoid — fix the call site instead of reverting.- Don't rewrite
ensure_future(some_future). When the argument is already aFutureorconcurrent.futures.Future-derived awaitable,ensure_futurereturns it (or wraps it) as-is; replacing withcreate_taskraisesTypeErrorbecausecreate_taskonly accepts coroutines. - Dropping the return value still loses the task. Both APIs return a handle the loop only weakly references. Migrating to
create_taskdoes not fix a fire-and-forget leak — you must retain the handle, as covered in Task Scheduling & Lifecycle. loop=is gone in 3.10+. Passingloop=to either function was removed; code still doing so will not run on modern Python and must be migrated regardless.- Type comments and casts mask the problem. A
# type: ignoreon aFuture.get_name()call hides the narrowing failure rather than fixing it. Prefer changing the producer tocreate_taskso theTasktype flows naturally.
Frequently Asked Questions¶
Is asyncio.ensure_future() completely removed in modern Python?
No. It remains in the standard library for backward compatibility and for normalizing inputs that may already be Futures or mixed awaitables. It is discouraged for scheduling a plain coroutine, where create_task() is clearer and better typed.
Does create_task() behave differently from ensure_future() at runtime?
For a coroutine input, both produce an asyncio.Task scheduled via loop.call_soon(), with identical exception and cancellation semantics. ensure_future() first runs branching to decide whether to wrap; create_task() goes straight to constructing the Task and is typed to return Task.
How do I safely migrate a large codebase from ensure_future() to create_task()?
Inventory call sites with grep or libCST, rewrite only those passing a single coroutine argument with no loop= keyword, leave Future-normalizing calls untouched, then validate with mypy --strict and pytest run with DeprecationWarning treated as error.
Can I still call create_task() outside a running event loop?
No. create_task() requires a running loop and raises RuntimeError if none is running, such as at import time. Schedule it from inside a coroutine run under asyncio.run() rather than reverting to ensure_future's implicit loop fallback.
Related¶
- Task Scheduling & Lifecycle — up to the overview for the full set of scheduling patterns and how a task moves through the ready queue.
- Asyncio Fundamentals & Event Loop Architecture — the loop model underneath both APIs: selector, timers, and executor integration.
- Debugging unawaited coroutines in large codebases — what happens when a coroutine reaches neither function and is never scheduled at all.