Skip to content

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 use asyncio.timeout() and exception groups from 3.11.
  • A running event loop. create_task() must be called from inside a coroutine (it requires asyncio.get_running_loop() to succeed). All snippets run under asyncio.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.

create_task vs ensure_future decision diagram Input type determines the call: a coroutine routes to create_task returning a Task; an existing Future or mixed awaitable routes to ensure_future which normalizes to a Future. Which scheduling call? What is the input? inspect its type A coroutine async def fn() Future / mixed awaitable of unknown type create_task() -> asyncio.Task ensure_future() -> Future (normalized)

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.

import asyncio
import inspect


async def work() -> int:
    await asyncio.sleep(0.01)
    return 42


async def main() -> None:
    coro = work()  # NOTE: a coroutine object, not yet running
    print("iscoroutine:", inspect.iscoroutine(coro))      # True
    print("isfuture:", asyncio.isfuture(coro))            # False

    task = asyncio.create_task(coro)  # correct call for a coroutine
    print("type:", type(task).__name__)                   # Task
    print("is Task:", isinstance(task, asyncio.Task))     # True
    print("result:", await task)


asyncio.run(main())

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.

import asyncio


async def work() -> int:
    await asyncio.sleep(0.01)
    return 7


async def main() -> None:
    t = asyncio.create_task(work(), name="named-task")
    f = asyncio.ensure_future(work())  # also a Task at runtime, Future to typers

    # Task API is available on the create_task result with no narrowing loss:
    print("name:", t.get_name())
    print("coro:", t.get_coro().__name__)

    # ensure_future is a Task at runtime here, but mypy sees Future and would
    # flag f.get_name() without an explicit cast/isinstance check.
    await asyncio.gather(t, f)


asyncio.run(main())

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().

import asyncio


async def work() -> None:
    await asyncio.sleep(0.05)


async def main() -> None:
    before = len(asyncio.all_tasks())
    t = asyncio.create_task(work())
    f = asyncio.ensure_future(work())
    after = len(asyncio.all_tasks())

    print("registered:", after - before)          # 2 — both scheduled now
    print("create_task -> Task:", isinstance(t, asyncio.Task))
    print("ensure_future -> Task:", isinstance(f, asyncio.Task))
    await asyncio.gather(t, f)


asyncio.run(main())

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.

import asyncio


async def fragile() -> int:
    await asyncio.sleep(0.02)
    raise RuntimeError("boom")


async def run_one(make) -> None:
    task = make(fragile())
    task.cancel()  # schedules CancelledError at next suspension
    try:
        await task
    except asyncio.CancelledError:
        print(f"{make.__name__}: cancelled cleanly")


async def main() -> None:
    await run_one(asyncio.create_task)
    await run_one(asyncio.ensure_future)


asyncio.run(main())

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.

import ast


class EnsureFutureRewriter(ast.NodeTransformer):
    def visit_Call(self, node: ast.Call) -> ast.AST:
        self.generic_visit(node)
        func = node.func
        if (isinstance(func, ast.Attribute)
                and func.attr == "ensure_future"
                and len(node.args) == 1        # single positional arg
                and not node.keywords):        # no loop= passed
            func.attr = "create_task"
        return node


src = "asyncio.ensure_future(work())\nasyncio.ensure_future(fut, loop=loop)\n"
tree = EnsureFutureRewriter().visit(ast.parse(src))
print(ast.unparse(tree))
# asyncio.create_task(work())
# asyncio.ensure_future(fut, loop=loop)   <- untouched: has loop=

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 existing Future/awaitable (typically with loop= or normalizing mixed inputs).
  • Clean strict typing: mypy --strict src/ raises no Future-vs-Task errors, and .get_name()/.set_name() calls type-check.
  • No deprecation noise: python -W error::DeprecationWarning -m pytest passes.
  • 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 before asyncio.run() raises RuntimeError: no running event loop. ensure_future() can fall back to get_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 a Future or concurrent.futures.Future-derived awaitable, ensure_future returns it (or wraps it) as-is; replacing with create_task raises TypeError because create_task only accepts coroutines.
  • Dropping the return value still loses the task. Both APIs return a handle the loop only weakly references. Migrating to create_task does not fix a fire-and-forget leak — you must retain the handle, as covered in Task Scheduling & Lifecycle.
  • loop= is gone in 3.10+. Passing loop= 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: ignore on a Future.get_name() call hides the narrowing failure rather than fixing it. Prefer changing the producer to create_task so the Task type 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.