Creating an asyncio task usually feels like a clean scheduling boundary: call asyncio.create_task(), keep the returned task, and let the event loop run the coroutine soon.
Python also supports eager task execution, where a coroutine can begin running immediately during task creation. Python 3.14 makes that choice directly available through the eager_start keyword on asyncio.create_task() and through task-group task creation.
That can remove scheduling overhead for coroutines that often complete without blocking. It can also change program ordering in ways that matter much more than the performance gain.
The important rule is simple: treat eager startup as an execution-semantic choice, not as a harmless optimization flag.
Understand the normal scheduling boundary
Consider a coroutine that does some synchronous work before its first await:
import asyncio
async def load_cached(key: str) -> str:
print("coroutine started")
value = cache.get(key)
if value is not None:
return value
return await fetch_remote(key)With ordinary deferred scheduling:
task = asyncio.create_task(load_cached("profile"))
print("task created")
result = await taskthe call creates and schedules a task. The coroutine runs when the event loop gets an opportunity to execute it.
That scheduling point is observable. Code immediately after create_task() can run before the coroutine body starts.
With Python 3.14, you can request eager startup for that individual task:
task = asyncio.create_task(
load_cached("profile"),
eager_start=True,
)The task can now begin executing the coroutine during the create_task() call itself. It runs until the coroutine blocks for the first time, returns, or raises.
If the cache contains the value, load_cached() may finish without ever being scheduled for a later event-loop turn.
Use eager startup where synchronous completion is common
The clearest candidate is a coroutine whose fast path often does not await anything.
For example:
async def get_user(user_id: int) -> User:
cached = users.get(user_id)
if cached is not None:
return cached
user = await database.fetch_user(user_id)
users[user_id] = user
return userIf most calls hit the in-memory cache, normal task creation introduces event-loop scheduling even though the coroutine has no asynchronous work on that path.
An eager task can execute the lookup and return immediately:
task = asyncio.create_task(
get_user(user_id),
eager_start=True,
)
user = await taskDo not assume this is automatically faster for an application as a whole. The benefit depends on how frequently coroutines complete synchronously, how many tasks are created, and whether task scheduling is meaningful in the workload. Measure the actual path before adopting eager execution broadly.
Expect ordering to change
Eager startup changes when user code runs.
Suppose task creation is surrounded by state changes:
state = []
async def child() -> None:
state.append("child")
async def main() -> None:
state.append("before")
task = asyncio.create_task(child(), eager_start=True)
state.append("after")
await taskBecause child() has no blocking await, it can append its value before create_task() returns. The resulting order can therefore be:
before
child
afterCode that relied on task creation behaving as a deferral point may observe different results.
This matters for more than lists in toy examples. Startup code may register callbacks, mutate shared in-memory state, acquire resources, emit logs, increment metrics, or call application hooks. Review everything before the coroutine’s first suspension point.
Do not use task creation as an implicit initialization barrier
A fragile pattern looks like this:
task = asyncio.create_task(worker(), eager_start=True)
registry[task] = metadataIf worker() assumes its task has already been registered, eager execution can violate that assumption because the worker starts before the assignment occurs.
Prefer explicit initialization. Pass required state into the coroutine or establish the registry entry before starting work when the architecture allows it.
For task metadata that depends on the returned task object, design the coroutine so it does not require that metadata before its first suspension point, or avoid eager startup for that task.
The broader lesson is that code after create_task() is not guaranteed to happen before an eagerly started coroutine body.
Keep the pre-await section short
Eager execution does not make synchronous work asynchronous.
This coroutine is a poor eager-task candidate:
async def transform(payload: bytes) -> bytes:
result = expensive_cpu_transform(payload)
await persist(result)
return resultWith eager startup, expensive_cpu_transform() runs synchronously inside task creation until the coroutine reaches await persist(...). That work occupies the event-loop thread just as any other synchronous Python code would.
If task creation happens in a loop, a long pre-await section can delay unrelated callbacks and tasks:
for payload in payloads:
asyncio.create_task(transform(payload), eager_start=True)Do not interpret eager_start=True as a parallelism feature. CPU-heavy work still needs an appropriate execution strategy, such as a process or interpreter pool when the workload and data-transfer costs justify one.
Remember that an eager task can already be done
After eager creation, the returned task may already have completed.
That means code such as this must not assume a pending state:
task = asyncio.create_task(maybe_cached(), eager_start=True)
if task.done():
result = task.result()
else:
result = await taskUsually you do not need the branch; simply awaiting an already completed task works:
task = asyncio.create_task(maybe_cached(), eager_start=True)
result = await taskBut lifecycle instrumentation, registries, and tests sometimes inspect done(). Make those components tolerate immediate completion.
A done callback added after eager completion still belongs to the task API, but do not build correctness around a callback being installed before the coroutine can finish. If observation must surround execution itself, put that observation inside the coroutine or a wrapper coroutine.
Handle exceptions through the task contract
An eagerly started coroutine can raise before it ever blocks:
async def parse_config() -> Config:
return Config.from_text(current_text)If parsing fails during eager startup, the task becomes failed immediately. Code consuming the task still needs to retrieve that exception:
task = asyncio.create_task(parse_config(), eager_start=True)
config = await taskDo not create eager tasks and discard them merely because you expect their fast path to succeed. Background tasks need ownership and exception handling just like normally scheduled tasks.
Structured concurrency is often the better fit when several related tasks must succeed together.
Use eager startup with TaskGroup deliberately
asyncio.TaskGroup owns its child tasks, waits for them, and coordinates failures. In Python 3.14, its create_task() accepts the task-creation options needed to pass eager startup through to the event loop:
async with asyncio.TaskGroup() as group:
profile = group.create_task(
get_profile(user_id),
eager_start=True,
)
permissions = group.create_task(
get_permissions(user_id),
eager_start=True,
)This preserves the task group’s lifecycle guarantees, but it does not restore deferred ordering. The first coroutine may run synchronously during the first group.create_task() call before the second child is even created.
That can matter when sibling tasks interact through shared state. Do not assume all siblings are constructed before any sibling begins execution.
If simultaneous admission is part of your algorithm, eager startup is the wrong mechanism unless you add an explicit synchronization barrier inside the children.
Understand the default when eager_start is omitted
The eager_start argument can be left unspecified:
asyncio.create_task(coro())In Python 3.14, omission allows the event loop’s configured task factory to determine the mode. This is important in applications or frameworks that install an eager task factory globally.
If a specific call site requires deferred execution for correctness, make that requirement explicit:
asyncio.create_task(coro(), eager_start=False)Likewise, use eager_start=True when eager behavior is intentional at that call site.
Explicit values make execution assumptions visible during review and reduce surprises when task-factory configuration changes elsewhere in the application.
Distinguish per-task eager startup from an eager task factory
Python 3.12 introduced asyncio.eager_task_factory(). An application can install it on an event loop so tasks begin eagerly by default:
loop = asyncio.get_running_loop()
loop.set_task_factory(asyncio.eager_task_factory)That is a broad policy change. It can affect task creation throughout code using that loop, including library code that was written with ordinary scheduling order in mind.
Python 3.14’s per-task eager_start option makes targeted adoption easier. Instead of changing every compatible task implicitly, you can choose eager behavior at call sites whose semantics and performance characteristics you understand.
A global eager factory can still be appropriate in controlled systems, but test the entire asynchronous application under that policy. The semantic surface is much larger than a single optimized cache lookup.
Preserve context assumptions
Task creation also interacts with contextvars. By default, a task receives a copy of the current context, and asyncio.create_task() can accept an explicit context= argument.
Eager startup does not mean you should depend on later context mutations being visible to the child:
request_id.set("request-a")
task = asyncio.create_task(handle(), eager_start=True)
request_id.set("request-b")The task’s context is determined as part of task creation. Design request-scoped state around that task boundary rather than expecting the parent’s later changes to flow into an existing task.
If you pass an explicit context, keep its ownership rules clear. Context propagation and eager execution are separate concerns even though both are configured at task creation.
Be careful with locks and synchronous callbacks
A coroutine can run arbitrary synchronous Python code before its first await. That includes invoking callbacks supplied by other parts of the application.
If a caller creates an eager task while it is temporarily maintaining an invariant, the child can observe the system in that intermediate state:
items.append(item)
task = asyncio.create_task(notify(item), eager_start=True)
indexes[item.id] = len(items) - 1If notify() reads indexes before awaiting, it can observe the new item without its index.
Fix the invariant rather than adding a scheduling assumption:
items.append(item)
indexes[item.id] = len(items) - 1
task = asyncio.create_task(notify(item), eager_start=True)Similar reasoning applies around locks, transaction-like in-memory updates, and callback registries. Finish synchronous state transitions before starting code that is allowed to observe them.
Benchmark the right comparison
A microbenchmark that creates a coroutine returning a constant will emphasize scheduling overhead:
async def immediate() -> int:
return 1That can demonstrate the mechanism, but production decisions need representative measurements.
Separate at least these cases:
- tasks that complete before their first blocking await;
- tasks that almost immediately block on I/O;
- tasks with meaningful synchronous work before blocking;
- bursts that create many tasks in one event-loop turn;
- workloads where ordering-sensitive callbacks or instrumentation run at startup.
Measure throughput and latency, but also watch event-loop responsiveness. Moving work into the create_task() call can reduce scheduling overhead while increasing the duration of the caller’s uninterrupted synchronous execution.
Test both scheduling modes when a component supports both
Concurrency tests often pass accidentally because one particular scheduler ordering is common on a developer machine.
If your abstraction promises to work with either eager or deferred task startup, exercise both explicitly:
import asyncio
import pytest
@pytest.mark.parametrize("eager", [False, True])
@pytest.mark.asyncio
async def test_operation(eager):
task = asyncio.create_task(operation(), eager_start=eager)
assert await task == expectedAdd tests for ordering-sensitive boundaries as well:
- synchronous completion before
create_task()returns; - a coroutine raising before its first
await; - a coroutine blocking immediately;
- task-group siblings where one completes eagerly;
- context-variable values captured at creation;
- cancellation after a task has already completed eagerly;
- instrumentation that registers tasks after creation.
Tests should verify application invariants rather than asserting an incidental event-loop order unless that order is deliberately part of the design.
Plan version compatibility
The asyncio.Task constructor has supported eager startup since Python 3.12, and Python 3.12 also introduced the eager task factory. The high-level asyncio.create_task(..., eager_start=...) interface is a Python 3.14 addition, along with task-creation keyword forwarding that makes the option available through TaskGroup.create_task().
Code using the Python 3.14 call signature will not run unchanged on older supported versions.
For a library spanning several Python releases, avoid blindly forwarding eager_start unless the runtime supports it. More importantly, do not silently emulate eager execution with direct coroutine driving; task state, cancellation, context, custom task factories, and event-loop integration make that a much more complicated contract than simply calling send(None).
Make the scheduling contract visible
Eager task startup is most useful when a coroutine has a frequent synchronous fast path and the application can benefit from avoiding an extra scheduling step.
Its cost is semantic: coroutine code can run before task creation returns, tasks can finish immediately, sibling creation order becomes observable, and synchronous work before the first blocking await stays on the caller’s event-loop turn.
Use eager_start=True where those consequences are understood and measured. Use eager_start=False where deferred startup is part of correctness. When either mode is acceptable, test both.
That turns eager execution from a surprising global optimization into an explicit concurrency decision at the point where a task enters the system.