Python Event Loop Fundamentals

This article is for developers new to asynchronous Python. It answers these questions:

  • What exactly is an event loop?
  • How does it relate to coroutines, Tasks, and Futures?
  • What does the event loop do when await is reached?
  • After a network request completes, how does the loop know which Task to resume?
  • Why do two consecutive await expressions run sequentially?
  • Why can create_task() introduce concurrency?
  • What do the ready queue, timer queue, and I/O selector each do?
  • What kinds of code block the event loop?

This article follows the current Python 3.14.6 documentation. Python describes the event loop as the core of every asyncio application: it runs asynchronous Tasks and callbacks, handles network I/O, and manages facilities such as subprocesses. For ordinary applications, the documentation recommends high-level APIs such as asyncio.run() instead of manual event-loop management. (Python documentation)


1. What Is an Event Loop?

In one sentence:

An event loop is a continuously running scheduler. It decides which Task should execute now and which waiting Tasks are ready to resume.

It does not execute all your business logic for you, and it does not automatically turn blocking synchronous code into asynchronous code.

Suppose there are three tasks:

Task A: waiting for an HTTP response
Task B: waiting for a database query
Task C: processing data

The event loop might work like this:

Run Task A
→ A sends an HTTP request and begins waiting
→ suspend A

Run Task B
→ B sends a database request and begins waiting
→ suspend B

Run Task C
→ C processes a piece of work
→ C reaches await and suspends

The HTTP response arrives
→ resume A

The database result arrives
→ resume B

asyncio uses cooperative scheduling. In one event-loop thread, only one Task runs at a time. When the current Task reaches an await that must wait, it is suspended; the event loop can then run another Task or callback, or process I/O. (Python documentation)


2. An Event Loop Is Not a Thread Pool

Beginners often imagine an event loop as “many background threads that execute code for me.” That is inaccurate.

Typically:

One event loop
runs in one operating-system thread
and executes one Task at a time

Its main way to achieve high concurrency is not to create many threads. Instead:

A Task pauses while waiting for I/O
→ the current thread runs another Task
→ when I/O completes, the original Task resumes

It is more like a scheduling center with one worker:

The worker cannot process two documents at once,
but while one document waits for an outside approval,
the worker can process another document.

Python documents that an event loop normally runs in the main thread and executes all callbacks and Tasks in that thread. While a Task is running, other Tasks in the same thread cannot run. Only when the current Task is suspended by await can the event loop run the next Task. (Python documentation)


3. Five Core Roles

To understand the event loop, first distinguish these objects.

ConceptRole
Coroutine functionA function defined with async def
Coroutine objectThe object produced by calling a coroutine function
TaskWraps a coroutine for execution scheduled by the event loop
FutureRepresents a result that will be available later
Event loopSchedules Tasks, callbacks, timers, and I/O

Their relationship can be pictured as:

async def function
      ↓ call it
Coroutine object
      ↓ create_task()
Task
      ↓ reaches await
wait for a Future / Future-like object
      ↓ Future completes
Task resumes

Python lists coroutines, Tasks, and Futures as the three main kinds of awaitable. A Task runs a coroutine in the event loop; a Future is a low-level awaitable representing the future result of an asynchronous operation. (Python documentation)


4. Coroutine Functions and Coroutine Objects

Start with this code:

async def fetch_data() -> str:
    return "done"

Here:

fetch_data

is a coroutine function.

Calling it:

coroutine = fetch_data()

produces a coroutine object.

The important point is:

Calling a coroutine function does not automatically schedule it on the event loop.

The following code normally does not actually run fetch_data:

fetch_data()

It only creates a coroutine object. If you neither await it nor wrap it in a Task, Python will usually emit:

RuntimeWarning: coroutine was never awaited

One correct option is:

result = await fetch_data()

Another is:

task = asyncio.create_task(fetch_data())
result = await task

Python explicitly states that simply calling a coroutine function creates a coroutine object; it does not schedule execution. (Python documentation)


5. What Is a Task?

A Task can be understood as:

A coroutine execution instance that has already been handed to the event loop for management.

For example:

task = asyncio.create_task(fetch_data())

This does the following:

fetch_data()
→ create a coroutine object
→ create_task() wraps it in a Task
→ the Task is scheduled to run in the event loop

A Task keeps advancing its coroutine:

run the coroutine
→ encounter await
→ suspend
→ wait for a Future
→ Future completes
→ resume the coroutine
→ finally store a return value or exception

A Task is Future-like. It runs a Python coroutine and can be awaited, cancelled, and queried for its result or exception. (Python documentation)


6. What Is a Future?

A Future is like a “future-result box.”

Now:
the result is not ready

Later:
there may be a value,
an exception,
or cancellation.

Its typical state is:

PENDING
FINISHED

or:

PENDING
CANCELLED

A Future normally does not execute business code itself. It expresses this idea:

An asynchronous operation will finish later, and its result will be placed here when it does.

For example:

future = loop.create_future()

Some other code may later run:

future.set_result("hello")

The waiter can write:

result = await future

Futures primarily connect low-level callback-based asynchronous code with high-level async / await code. Normal application code usually does not need to create Futures itself. (Python documentation)


7. What asyncio.run() Does

Most standalone Python programs start like this:

import asyncio


async def main() -> None:
    print("start")
    await asyncio.sleep(1)
    print("end")


asyncio.run(main())

Roughly speaking, asyncio.run() creates an event loop, runs main() as its top-level asynchronous task, keeps the loop running until that task completes, shuts down asynchronous generators and the default executor, and then closes the loop. It is normally called once as an asyncio program’s main entry point. It cannot be called while another event loop is already running in the same thread. (Python documentation)

Frameworks such as FastAPI and Jupyter already run an event loop, so application code normally uses await some_coroutine() rather than calling asyncio.run(...) again.


8. What the Event Loop Manages Internally

As a mental model, the loop manages four kinds of work:

Event loop
├── ready queue
├── timer / scheduled queue
├── I/O registrations
└── Future completion callbacks

Ready queue

The ready queue holds callbacks or Task steps that can run now. For example, loop.call_soon(callback) schedules a callback for the next loop iteration. When a Future completes, the callback that resumes its waiting Task also enters this queue. CPython’s default loop uses an internal _ready deque; it is an implementation detail and application code must not access it directly. (Python documentation)

Timer queue

The timer queue holds callbacks that may run only in the future. loop.call_later(5, callback) schedules one for about five seconds later; loop.call_at(deadline, callback) schedules one at a loop-clock deadline. These calls return a TimerHandle, which can cancel a callback that has not yet run. The loop uses a monotonic clock to avoid disturbance from wall-clock changes. asyncio.sleep() uses the loop’s timer machinery and always suspends the current Task. (Python documentation) (Python documentation)

I/O registrations

When code awaits network data, for example data = await reader.read(1024), the loop does not repeatedly poll in Python code. Instead, it registers interest with the operating system: notify me when this socket becomes readable. Unix selector mechanisms can watch many file descriptors and return those that are readable or writable. Typical platform primitives are epoll on Linux, kqueue on macOS and BSD, and poll or select elsewhere. asyncio and its networking libraries encapsulate these details. (Python documentation)

Future completion callbacks

If result = await future finds an unfinished Future, the waiting Task registers a callback that will wake it after completion and then suspends. future.add_done_callback(callback) does not run the callback immediately in the current stack; the loop schedules it with loop.call_soon(). This is the mechanism that makes Task resumption possible. (Python documentation)


9. One Event-Loop Iteration

This is a useful model rather than a promise about every loop implementation:

1. inspect callbacks and Task steps that are ready
2. calculate when the next timer expires
3. wait for or poll operating-system I/O as appropriate
4. turn completed I/O events into callbacks
5. move expired timers to the ready queue
6. run ready callbacks and Task steps
7. callbacks may complete Futures
8. completed Futures schedule their waiting Tasks to wake
9. begin the next iteration

In CPython’s selector-based default loop, ready work normally makes the I/O poll use a zero timeout. When nothing can run immediately, the loop can wait until the nearest timer expires or an I/O event becomes ready. (Python documentation)


10. What Happens at await

For:

result = await operation()

do not simplify the operation to “the event loop executes operation().” A more accurate sequence is:

1. the current Task calls operation()
2. operation() returns an awaitable
3. the current Task drives that awaitable
4. if it can finish immediately, obtain the result
5. otherwise suspend the current Task
6. let the event loop run other work
7. resume when the awaited object completes
8. produce a value or raise an exception at await

await does not necessarily switch Tasks. An already-complete awaitable can return immediately. await asyncio.sleep(...), however, always suspends the current Task and gives other work an opportunity to run. (Python documentation)


11. How the Loop Learns That an Awaitable Completed

Suppose result = await future awaits an unfinished Future:

Task A waits for Future F
→ register a wake-up callback on F
→ suspend Task A

Later, some event calls future.set_result(value), future.set_exception(error), or future.cancel(). The Future becomes done, its done callbacks are scheduled, the callback that wakes Task A enters the ready queue, and a later loop iteration resumes Task A. The await then returns the value or raises the saved exception. (Python documentation)

For network I/O, the loop registers socket readiness with the operating system; when data arrives, the selector or IOCP notifies the loop, the networking layer reads data and completes a Future, and the waiting Task becomes ready. SelectorEventLoop uses the selectors module, while Windows’ ProactorEventLoop uses I/O Completion Ports. (Python documentation)

For await asyncio.sleep(5), a timer expires after five seconds, completes the corresponding Future, and puts the Task back in the ready queue. For await asyncio.to_thread(blocking_function), the worker thread completes its work and notifies the loop safely; the Future completes and the Task resumes. Cross-thread callbacks use loop.call_soon_threadsafe(), while submitting a coroutine from another thread uses asyncio.run_coroutine_threadsafe(). (Python documentation)


12. Why Consecutive await Expressions Are Sequential

result_a = await fetch_a()
result_b = await fetch_b()

The second line belongs to the same current Task. When it suspends at the first line, the entire Task is paused there:

main Task starts
→ call fetch_a()
→ wait for A
→ suspend main Task

the event loop may run other Tasks that already exist,
but fetch_b() has not been called and no Task represents B

A completes
→ resume main Task
→ execute the next line
→ call fetch_b()

The event loop does not read ahead through source code and split a later statement into a new Task. Therefore two direct awaits are sequential control flow. To run independent work concurrently, use create_task() or TaskGroup. (Python documentation)


13. Why create_task() Enables Concurrency

task_a = asyncio.create_task(fetch_a())
task_b = asyncio.create_task(fetch_b())

result_a = await task_a
result_b = await task_b

This creates two independent Tasks. When the main Task awaits task_a and suspends, the event loop can still run both Task A and Task B. They can therefore make progress while each waits for I/O.

If A and B each wait for two seconds, direct consecutive awaits take about four seconds; creating both Tasks first makes the total roughly two seconds, plus overhead. This is concurrency, not parallel execution of Python bytecode in the same event-loop thread.


14. Callbacks and Coroutines

A callback is an ordinary callable that the loop invokes at a scheduled time, for example through loop.call_soon() or loop.call_later(). A coroutine is code defined with async def that a Task advances and may suspend at await. Callbacks are useful at the low-level event-loop boundary; application code normally expresses asynchronous flow with coroutines and Tasks.

Handle represents a callback scheduled with call_soon(). TimerHandle represents one scheduled by call_later() or call_at(). Both can be cancelled before execution; they are scheduling records, not Tasks or threads. (Python documentation)


15. What Blocks the Event Loop?

The following work blocks the loop when it runs in the event-loop thread:

time.sleep(5)
requests.get("https://example.com")
large_result = cpu_heavy_calculation()

Writing await time.sleep(5) does not fix this. time.sleep(5) first blocks synchronously and returns None; then await None raises an error. Use the asynchronous alternative for waiting:

await asyncio.sleep(5)

Bridge unavoidable blocking I/O to a worker thread:

result = await asyncio.to_thread(blocking_function)

For CPU-heavy work, a thread may keep the loop responsive but does not necessarily provide multi-core parallelism because of the GIL. Consider a process pool or a separate worker system when true parallel CPU execution is required.


16. Event Loops and Threads

An event loop is tied to a thread. A thread can have an event loop, but a loop runs its callbacks and Tasks in its own thread. Most applications use one loop in the main thread. If another thread must notify that loop, use thread-safe APIs such as:

loop.call_soon_threadsafe(callback)
asyncio.run_coroutine_threadsafe(coro, loop)

Do not manipulate an event loop’s non-thread-safe objects directly from another thread. (Python documentation)


17. Unix and Windows Event Loops

On Unix, the common default is selector-based and waits for readiness events such as readable or writable sockets. On Windows, ProactorEventLoop is based on I/O Completion Ports. Application-level asyncio code should normally rely on the high-level APIs rather than on a platform-specific polling mechanism. (Python documentation)


18. Cancellation and Timeouts

Cancellation and timeouts are also scheduled through the event loop. Calling task.cancel() requests cancellation; the Task receives CancelledError at an appropriate suspension point. A timeout can be expressed with asyncio.timeout() or asyncio.wait_for().

async with asyncio.timeout(10):
    result = await call_external_service()

Cancellation is cooperative: code that blocks the event-loop thread cannot respond until it returns control. Clean up resources in finally blocks and avoid swallowing CancelledError unintentionally.


19. The Event Loop Does Not Guarantee Absolute Fairness

The loop gives ready callbacks and Tasks opportunities to run, but it is not an absolute fairness guarantee. A callback that performs a long computation still monopolizes the thread. Split long work into smaller pieces, await genuine asynchronous operations, or move blocking work out of the loop.


20. Debugging Event-Loop Problems

During development, enable asyncio debug mode and look for warnings such as coroutine was never awaited and Task exception was never retrieved. Inspect whether an object is a coroutine, Task, Future, or ordinary value before awaiting it. Slow-callback warnings and task traces are often the fastest way to find code that blocked the loop. (Python documentation)


21. Practical Examples in AI Applications

Call independent tools concurrently

async with asyncio.TaskGroup() as group:
    weather_task = group.create_task(get_weather())
    search_task = group.create_task(search_documents())

Independent network calls can overlap their I/O waits.

Keep dependent agent steps sequential

plan = await make_plan()
answer = await execute_plan(plan)

The second operation depends on the first result, so direct sequential awaits are correct.

Stream LLM output

An async generator can await the next network chunk, yield a token, and then await the next chunk. While it waits, the event loop can process other requests.

Isolate synchronous parsers

text = await asyncio.to_thread(parse_document, path)

This keeps a synchronous parser from blocking the service’s event-loop thread.


22. Beginner Best Practices

  1. Use asyncio.run() as the entry point of a standalone program.
  2. Prefer TaskGroup for related concurrent Tasks.
  3. Do not run synchronous blocking I/O in the event loop.
  4. Do not assume async def automatically means non-blocking.
  5. Do not assume await automatically creates concurrency.
  6. Put timeouts around external calls.
  7. Limit concurrency when a downstream service has capacity limits.
  8. Enable debug mode while developing asynchronous code.

23. Common Misconceptions

“The event loop executes many Tasks at the same instant”

Not in one loop thread. Tasks take turns between waiting points.

“The event loop automatically runs the next line”

No. The next line of a suspended Task waits too; only existing independent Tasks can run.

“A Future executes work by itself”

No. A Future represents a result and state. A network callback, timer, Task, thread, or lower-level library completes it.

“Any await prevents blocking”

No. The awaited operation itself must yield control rather than synchronously block first.

“A Task is a thread”

No. A Task is a logical coroutine-execution unit normally running in the event-loop thread.

“Async fits every program”

No. It is most valuable for many I/O waits. A simple synchronous script or CPU-heavy calculation may not benefit.


Conclusion

Use this chain to remember Python’s event loop:

asyncio.run()
→ starts and manages the event loop

Coroutine
→ describes asynchronous code

Task
→ hands a coroutine to the event loop for scheduling

Task reaches await
→ waits for a Future
→ Task suspends

Event loop
→ runs other ready Tasks
→ waits for timers or I/O

I/O / timer / thread completes
→ Future becomes done
→ a wake-up callback enters the ready queue

next loop iteration
→ the original Task resumes
→ await receives a result

The key idea is:

The event loop does not jump arbitrarily among Tasks. A Task voluntarily pauses while waiting; the loop chooses another ready Task, and when the wait completes, a Future callback places the original Task back into the ready queue.