The Python asyncio event loop doesn’t actually run your code; it orchestrates when your code gets a chance to run by cleverly managing callbacks.

Let’s watch it in action. Imagine you have a simple asynchronous function:

import asyncio

async def greet(name):
    print(f"Starting to greet {name}...")
    await asyncio.sleep(1) # Simulate I/O
    print(f"Finished greeting {name}!")
    return f"Hello, {name}!"

async def main():
    task1 = asyncio.create_task(greet("Alice"))
    task2 = asyncio.create_task(greet("Bob"))

    print("Tasks created, awaiting results...")
    result1 = await task1
    result2 = await task2
    print(f"Results: {result1}, {result2}")

if __name__ == "__main__":
    asyncio.run(main())

When you run this, you’ll see:

Tasks created, awaiting results...
Starting to greet Alice...
Starting to greet Bob...
Finished greeting Alice!
Finished greeting Bob!
Results: Hello, Alice!, Hello, Bob!

Notice how "Starting to greet Alice…" and "Starting to greet Bob…" appear almost immediately, followed by the "Finished" messages after a delay. This is the event loop’s magic. asyncio.run(main()) is the entry point. It sets up an event loop, schedules the main coroutine to run on it, and then enters a while loop that keeps running until there’s nothing left to do.

Inside main, asyncio.create_task(greet("Alice")) doesn’t execute greet("Alice") right away. Instead, it wraps the greet coroutine in a Task object and tells the event loop, "Hey, when you get a chance, start running this greet coroutine." The same happens for greet("Bob").

When the loop does get a chance to run main, it hits result1 = await task1. This is where the actual execution of greet("Alice") begins. It prints "Starting to greet Alice…", then hits await asyncio.sleep(1). This await doesn’t block the entire program. Instead, it tells the event loop: "I need to wait for 1 second. Please go do something else, and come back to me when the 1 second is up." The event loop then immediately switches context.

Since task1 is now "waiting" for sleep(1), the event loop looks for other ready tasks. It finds task2 (which was created but not yet awaited) and starts executing greet("Bob"). It prints "Starting to greet Bob…", hits its await asyncio.sleep(1), and again tells the event loop to come back later.

Now, the event loop has two tasks waiting for timers. It checks if any timers have expired. After approximately 1 second, the timers for both task1 and task2 expire. The event loop adds the corresponding callbacks (to resume greet("Alice") and greet("Bob")) back into its ready queue. When the loop picks up task1 again, it resumes execution after the await asyncio.sleep(1), prints "Finished greeting Alice!", and returns. This result is then assigned to result1.

The main coroutine then hits result2 = await task2. Since task2 has already finished its sleep and its callback is ready, the loop immediately resumes greet("Bob"), prints "Finished greeting Bob!", and returns its result, which is assigned to result2. Finally, main prints the results and finishes. The event loop, seeing no more tasks, exits.

The core of the event loop is a while loop that continuously checks three main queues:

  1. Ready Queue: Tasks that are ready to run their next step. This includes newly created tasks, tasks whose awaitable completed, or tasks whose timers expired.
  2. Callbacks: Functions scheduled to be called at a specific time or after an event.
  3. File Descriptors (for I/O): Waiting for data on sockets or other file descriptors.

The loop’s strategy is simple: pick something from the ready queue and run it until it yields control (awaits something). If it awaits an I/O operation, the loop registers that I/O with the underlying OS (e.g., epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) and goes back to the start of its while loop to find something else to run. If it awaits a timer, it adds the callback to a timer wheel and keeps looking.

The most surprising thing about the event loop is how it uses a single thread to achieve concurrency. It’s not true parallelism; only one Python instruction is executing at any given micro-moment. The illusion of simultaneous execution comes from rapidly switching between tasks whenever one of them voluntarily pauses (by awaiting).

This cooperative multitasking means that a long-running, blocking operation within an async function, like a synchronous time.sleep(10) or a blocking I/O call, will freeze the entire event loop. No other task, no matter how urgent, can run until that blocking operation completes. This is why you must always use asyncio-compatible libraries for I/O and avoid blocking calls within your async functions.

The next concept you’ll encounter is how asyncio handles different types of awaitables, such as Futures and Tasks, and how they interact with the loop’s internal mechanisms for scheduling and cancellation.

Want structured learning?

Take the full Python course →