2025, Sep 24 13:00

Asyncio Task Start Timing Explained: Event Loop Scheduling, create_task vs await, and Python 3.12 Eager Tasks

Learn when asyncio tasks start, how the event loop schedules create_task coroutines, what await changes, and how Python 3.12 eager tasks affect execution order.

Understanding when asyncio tasks actually start running is one of those details that can make concurrent code feel unpredictable. Let’s walk through a minimal case, see what the event loop is doing, and clear up why the first message you see is “Do something with 1...” even though you await task 2 first.

Problem setup

Consider this program. It spins up two tasks, then awaits the second one before awaiting the first. The observable behavior is the source of confusion.

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))
    out_b = await job_b
    print("Task 2 fully completed")
    out_a = await job_a
    print("Task 1 fully completed")
    return [out_a, out_b]

outputs = asyncio.run(orchestrate())
print(outputs)

The output is:

Do something with 1...
Do something with 2...
Done with 1
Done with 2
Task 2 fully completed
Task 1 fully completed
['Result of 1', 'Result of 2']

At first glance, you might expect “Do something with 2...” to appear before anything related to 1, because the await goes to task 2. But the event loop’s scheduling explains why task 1 gets to speak first.

What’s actually happening

When you call asyncio.create_task(get_payload(1)) and asyncio.create_task(get_payload(2)), both tasks are created and can be scheduled. They don’t preempt the currently running coroutine. The coroutine orchestrate keeps running until it reaches an await or completes. Only then can other tasks be run by the event loop.

The key moment is out_b = await job_b. That await yields control back to the event loop and allows other ready tasks to run. The next task to be scheduled is the one created first. So job_a begins executing and runs until it reaches its first await, which prints “Do something with 1...” and then awaits asyncio.sleep(1). With job_a now suspended, the loop schedules job_b, which prints “Do something with 2...” and awaits asyncio.sleep(2). From there, each task resumes as its sleep completes, which is why you see “Done with 1” before “Done with 2”.

A quick visibility trick

To make the scheduling point obvious, insert a line after both tasks are created and before the first await:

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))
    print('tasks created')
    out_b = await job_b
    print("Task 2 fully completed")
    out_a = await job_a
    print("Task 1 fully completed")
    return [out_a, out_b]

outputs = asyncio.run(orchestrate())
print(outputs)

The very first line you’ll see is:

tasks created

That’s because creating tasks doesn’t interrupt the currently running coroutine. Only when orchestrate hits an await does control return to the event loop, which then starts running other tasks one at a time. Since job_a was created first, it is scheduled first, and you’ll get the complete sequence:

tasks created
Do something with 1...
Do something with 2...
Done with 1
Done with 2
Task 2 fully completed
Task 1 fully completed
['Result of 1', 'Result of 2']

“But I didn’t await, and they still printed”

Another common observation is that simply creating tasks appears to run coroutines even if you never await them:

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))

outputs = asyncio.run(orchestrate())
print(outputs)

Output:

Do something with 1...
Do something with 2...
None

This aligns with the same scheduling rule. The newly created tasks cannot run while orchestrate is executing. Once orchestrate completes, control returns to the event loop, which can now run ready tasks. The entry prints happen immediately when each task begins. The post-sleep prints do not appear because you didn’t wait for the tasks to finish. The final print is None because orchestrate doesn’t return anything.

Eager tasks in Python 3.12+

In Python 3.12 and newer, tasks can be created eagerly. In this mode, tasks start running immediately upon creation:

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    running = asyncio.get_running_loop()
    running.set_task_factory(asyncio.eager_task_factory)

    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))
    print('tasks created')
    out_b = await job_b
    print("Task 2 fully completed")
    out_a = await job_a
    print("Task 1 fully completed")
    return [out_a, out_b]

outputs = asyncio.run(orchestrate())
print(outputs)

In this case you’ll see:

Do something with 1...
Do something with 2...
tasks created
Done with 1
Done with 2
Task 2 fully completed
Task 1 fully completed
['Result of 1', 'Result of 2']

The difference is that the task factory changes when tasks begin executing.

Why this matters

Event-loop handoff points define concurrency in asyncio. The moment you yield, other tasks may run, and their order is influenced by when they were created or scheduled. Understanding this prevents incorrect assumptions about which coroutine will run first and helps avoid subtle bugs where tasks are created but never awaited, leading to partially executed work.

Takeaways

Create tasks with asyncio.create_task when you want concurrency, but remember that they don’t run until your code yields or completes, unless you opt into eager behavior in Python 3.12+. If you need results, await the tasks you created. If you only create tasks and return, you’ll see any immediate side effects that happen before the first await inside those tasks, but you won’t get completion and you won’t see post-await output.

The article is based on a question from StackOverflow by M a m a D and an answer by Booboo.