There are not many one-line changes that measurably speed up an async service. This is one of them — and it is also one that can quietly change the ordering your code depends on, so it is worth understanding rather than pasting.
What eager tasks do
Normally, asyncio.create_task(coro) schedules the coroutine and returns
immediately. Not one line of the coroutine body has run. It starts on the next
cycle of the event loop.
With the eager task factory (3.12):
loop = asyncio.get_running_loop()
loop.set_task_factory(asyncio.eager_task_factory)
create_task runs the coroutine body synchronously, during task
construction, up to its first genuine suspension. If it never suspends — a
cache hit, a validation that fails immediately, a fast path that returns
without I/O — it finishes right there, and no task is ever scheduled at all.
The documentation reports “making some use-cases 2x to 5x faster”. The win is not magic; it is the elimination of scheduling overhead for coroutines that did not need scheduling. In a service where a large fraction of async calls hit a cache, that fraction stops paying for task creation, ready-queue insertion, a loop iteration, and task destruction.
There is a per-task opt-in too: TaskGroup.create_task(coro, eager_start=True),
which is the safer way in — you get the win exactly where you have reasoned
about it.
The behaviour change
The coroutine body now runs before create_task returns.
Everything that follows is a consequence:
log.append("before")
task = asyncio.create_task(worker()) # worker() appends "worker"
log.append("after")
Lazily: ["before", "after", "worker"]. Eagerly: ["before", "worker", "after"] — assuming worker runs to a suspension point or to completion.
That difference is invisible in most code and fatal in a little of it:
- A task whose body reads state the caller mutates on the next line now reads the old value.
- A task that acquires a lock the caller is holding now deadlocks immediately instead of after the caller releases it.
- Tests that assert on ordering start failing — which is the good outcome, because it means the ordering was load-bearing and undocumented.
-
task.cancel()immediately aftercreate_taskno longer prevents the body from having run. Eagerly, the first chunk already happened.
💡If eager start is faster and mostly invisible, why is it not the default? click to reveal
Because “mostly invisible” is not a property you can impose on every existing program at once, and because the change is genuinely observable in the two cases the runtime cannot detect for you: ordering and re-entrancy.
Re-entrancy is the sharper of the two. Lazily, create_task is guaranteed not
to run user code — so calling it while holding a lock, or half-way through
mutating a data structure, is safe. Eagerly, create_task may run arbitrary
code, including code that touches the very structure you are in the middle of
updating, before it returns. Every create_task call site becomes a potential
re-entrancy point.
That is why the opt-in is per-loop or per-task rather than a version bump. It
is also why TaskGroup.create_task(..., eager_start=True) is the shape to
prefer: it makes the decision local to a call site you have thought about,
rather than global to a codebase you have not read all of.
The rest of the 3.12–3.14 performance story
Eager tasks were not the only change worth knowing:
-
3.12 also removed a copy from socket writes, and gave
asyncio.current_task()a C implementation — 4 to 6 times faster. That matters becausecurrent_task()is on the hot path of every contextvar-aware logging call. - 3.14 rewrote task bookkeeping: tasks live on a per-thread linked list, and the current task is stored on the thread state. Partly a straight speedup, partly preparation for free-threaded builds, where a global task registry would be a contention point. It did not change the ownership contract — the loop still holds only weak references, and gh-91887 is still open.
💡How would you decide whether the eager task factory is worth turning on for your service? click to reveal
Measure the fraction of your tasks that complete without suspending, and treat that as the ceiling on the win.
Concretely: before switching, instrument create_task call sites — or sample
with a profiler — and ask how many of those coroutines hit a fast path (cache
hit, early validation failure, a value already in memory) versus how many
actually await I/O. A service where 5% of tasks are trivially satisfiable will
not notice; one where 60% are will.
Then turn it on in a load test, not in production, and look at two things: p50 latency (which should improve) and your ordering-sensitive tests (which should still pass). If any test starts failing, you have found real coupling and you should fix that regardless of whether you keep the factory.
And if you want the win without the global risk, use
TaskGroup.create_task(..., eager_start=True) at the specific call sites your
measurement identified. That is the version you can defend in review.