Create your own
Lesson illustration

Running Asyncio Programs with `asyncio.run()`

Hello! Welcome to the final lesson in our introductory module on asynchronous programming.

Introduction

In our last lesson, we explored the asyncio event loop, the central scheduler that orchestrates the execution of coroutines. We saw how it manages a queue of ready tasks, runs them until they await, and resumes them when their awaited operations are complete. We concluded by mentioning that in modern Python, you rarely interact with the event loop directly.

Today, we'll focus on the high-level function that handles all this for you. The learning outcome for this lesson is to use asyncio.run() to execute a top-level coroutine and serve as the program's entry point. We will see how this single function call encapsulates the entire lifecycle of the event loop, providing a clean and simple way to start any asyncio application.

1. asyncio.run(): The Modern Entry Point

Before Python 3.7, starting an asyncio program involved more boilerplate code. You would typically need to get the event loop, use its run_until_complete() method, and then manually close it. This looked something like:

# The "old" way (pre-Python 3.7)
# loop = asyncio.get_event_loop()
# try:
#     loop.run_until_complete(main())
# finally:
#     loop.close()

This was verbose and error-prone. The introduction of asyncio.run() was a significant enhancement, providing a single, clean entry point.

asyncio.run() is a high-level function that handles the entire process:

  1. It creates a new event loop.
  2. It runs the coroutine you pass to it until that coroutine completes.
  3. It ensures all asynchronous generators are finalized and the default thread pool is shut down.
  4. Finally, it closes the loop.

Let's look at the official documentation for a formal definition.

Coroutines and Tasks — Python 3.9.2 documentation

The official Python documentation provides the most direct and authoritative description of asyncio.run().

Please read the section 'Running an asyncio Program'. Pay close attention to the function signature and the description of what it manages. Note the key phrase: 'It should be used as a main entry point for asyncio programs, and should ideally only be called once.'

2. Executing a Coroutine

A critical concept to grasp is the distinction between a coroutine function and a coroutine object. When you define a function with async def, you're creating a coroutine function. When you call that function, it doesn't execute the code inside. Instead, it returns a coroutine object.

This object is an awaitable, representing the computation that hasn't happened yet. asyncio.run() is the function that takes this object and tells the event loop to execute it.

import asyncio

async def my_coroutine():
    print("Executing the coroutine!")

# This creates the coroutine object but does nothing with it.
# If you run this script, you'll likely see a 'coroutine was never awaited' warning.
my_coroutine() 

# This is the correct way: pass the coroutine object to asyncio.run()
asyncio.run(my_coroutine()) 
# Expected output:
# Executing the coroutine!

The following video provides a clear demonstration of this distinction.

Asyncio in Python - Full Tutorial

The video 'Asyncio in Python - Full Tutorial' by Tech With Tim clearly explains the difference between calling a coroutine function and executing it.

Watch the segment from 3:44 to 6:25. The first part introduces asyncio.run as the entry point. The second part is crucial: it demonstrates what happens when you call an async function directly and why asyncio.run is necessary to actually execute the code.

3. Orchestrating the Application

Now, let's connect this back to the event loop's role as an orchestrator. asyncio.run() starts the event loop and executes the single top-level coroutine you provide—typically named main. The responsibility for creating and managing any other concurrent tasks falls to the code inside your main coroutine.

The asyncio.run() function will keep the event loop running until main and all the tasks it has spawned are complete.

Corey Schafer's video provides an excellent animated walkthrough of this process. It first shows a common but incorrect approach, which helps clarify how await and the event loop interact, before showing the correct pattern.

Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations

In his 'Complete Guide to Asynchronous Programming', Corey Schafer animates how asyncio.run() kicks off the process and how the structure of your main coroutine determines whether you achieve concurrency.

Please watch the following two segments: asyncio.run() Introduction (3:56 - 5:04): This is a quick recap of what asyncio.run() does, framing it as the engine starter. Sequential Execution (18:19 - 25:51): This is a critical section. It shows a main function, executed by asyncio.run(), that uses await sequentially. The animation clearly demonstrates why this results in blocking behavior, not concurrency. This reinforces what we learned about await pausing the current function.

After watching that, you can see that simply using async/await and asyncio.run() is not enough to get concurrency. The main coroutine must be designed to schedule tasks on the event loop so they can run concurrently.

While we will cover the functions for creating tasks (asyncio.create_task and asyncio.gather) in the next module, seeing them in action now provides the complete picture of asyncio.run()'s role.

Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations

Now, let's watch the follow-up segment from the same video, which demonstrates the correct approach.

Watch from 25:41 to 32:03. This animation shows how using asyncio.create_task within the main coroutine schedules tasks on the event loop. asyncio.run() starts main, main schedules the tasks, and the event loop then juggles them concurrently. This is the pattern you will use constantly.

This last video segment provides a perfect bridge from this module to the next. You've now seen all the fundamental components working together:

  • async def defines a coroutine.
  • await pauses a coroutine.
  • The event loop (from our last lesson) is the scheduler.
  • asyncio.run() (from this lesson) is the high-level entry point that manages the event loop.
  • asyncio.create_task (preview for the next module) is how you tell the event loop about new tasks to run.

Conclusion

This lesson concludes our introduction to the foundational concepts of asyncio. You now have a complete, high-level view of how an asynchronous application is structured and executed in Python.

Key Takeaways:

  • asyncio.run(coro) is the standard, high-level entry point for running an asyncio program.
  • It automatically creates an event loop, runs the provided top-level coroutine to completion, and handles all necessary cleanup.
  • Calling an async def function returns a coroutine object; it does not execute the function's code. This object must be passed to asyncio.run() or awaited by another coroutine.
  • asyncio.run() executes the main coroutine, which is then responsible for scheduling other tasks to achieve concurrency.

Preview of the Next Module:

We've laid all the groundwork. In the next module, "Managing Concurrent Operations," we will move from theory to practice. Our first lesson will focus on using asyncio.sleep() to simulate I/O-bound work, allowing us to build and analyze simple concurrent programs and see the performance benefits of the patterns we've just discussed.

Can't find a good explanation? Sign up and we'll make it for you

Sign up