Your service looks up users through a cache, and nine lookups in ten are cache hits. Each lookup runs in its own task. An ordinary task does not start when you create it. It waits for the event loop's next turn. For a cache hit that never waits for anything, that delay is pure overhead.
An eager task skips the delay. It starts running its coroutine inside
create_task itself, and keeps going until it reaches an await that has to
wait, or until it finishes.
import asyncio
async def main(eager: bool) -> None:
if eager:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
log: list[str] = []
async def worker() -> None:
log.append("worker")
log.append("before")
task = asyncio.create_task(worker())
log.append("after")
await task
print(log)
asyncio.run(main(eager=False)) # ['before', 'after', 'worker']
asyncio.run(main(eager=True)) # ['before', 'worker', 'after']
With an ordinary task, create_task only schedules worker, so "after"
comes first. With an eager task, worker runs to the end before
create_task returns.
Turn on eager tasks for the whole loop
Python 3.12 added asyncio.eager_task_factory. Install it with
loop.set_task_factory once, at the top of your main coroutine. From then on,
every task on that loop is eager, including the tasks your libraries create.
Each asyncio.run call makes a fresh loop, so the example can try both
settings.
You still get an ordinary Task back. Await it, or create it through a
TaskGroup, as before. If the coroutine raises, the task stores the
exception and await task raises it, the same as with an ordinary task.
The task runs until its first real wait
An await pauses a task only when the result is not ready yet. Awaiting a
coroutine that returns without waiting does not pause the task, and neither
does awaiting a future that is already done. So an eager task can pass several
await lines before one of them pauses it. A cache hit may never pause at
all, and then the whole task finishes inside create_task.
Common mistakes
Three habits that worked with ordinary tasks cause bugs with eager ones.
- Setting up shared state after creating the task. The task may already
have read it. Prepare everything the task needs before you call
create_task. - Cancelling a task right after creating it. By the time you call
task.cancel(), the eager part has already run. If the task has finished,cancel()returnsFalseand its effects stay. - Slow synchronous work before the first
await. That work now runs insidecreate_task, so the caller waits for it. Eager tasks run your code sooner, on the same thread. The work still blocks the event loop.
Check your understanding
You hold an asyncio.Lock and create an eager task that needs the same lock. Does the program deadlock?
Show answer
Hide answer
No. The task runs until async with lock finds the lock taken, then pauses
there, and create_task returns. Your code carries on and releases the lock,
and the task gets it next.
You would deadlock by awaiting the task while you still hold the lock. That deadlocks with ordinary tasks too.
Measure before you switch
Eager tasks save time when many tasks finish without waiting, such as cache hits or results that are already computed. Benchmark with realistic hit and miss rates, and compare throughput and latency before and after the change.
If a test that checks ordering fails after the switch, your code relied on
the old order. Make that order explicit, for example with an
asyncio.Event, or keep ordinary tasks on that loop.
Going further
Python 3.14 lets you make a single task eager without changing the loop.
Pass eager_start=True to asyncio.create_task or TaskGroup.create_task.
Python 3.12 and 3.13 reject that keyword with a TypeError. If you use a
custom event loop or task factory, check that it supports eager start.
Read more in the asyncio documentation on eager tasks.