Lesson 71 · Advanced Python

Async Python

Learn how Python handles asynchronous programming using async, await, coroutines, tasks, the event loop, and asyncio to efficiently handle I/O-bound work.

50–60 minutes Intermediate Advanced Python
0%
01

Learning Objectives

Asynchronous programming becomes particularly useful when an application spends a lot of time waiting for external operations such as network requests, databases, files, or other services.

By the end of this lesson, you will be able to:

  • Understand synchronous and asynchronous execution.
  • Explain what blocking I/O means.
  • Understand Python coroutines.
  • Use async def.
  • Use await correctly.
  • Understand the event loop.
  • Use Python's asyncio module.
  • Create and manage asynchronous tasks.
  • Run multiple coroutines concurrently.
  • Use asyncio.gather().
  • Handle asynchronous exceptions.
  • Work with timeouts and task cancellation.
  • Understand async generators.
  • Recognize when async programming is useful.
  • Connect async programming with FastAPI.
02

What Is Asynchronous Programming?

In normal synchronous programming, operations generally execute one after another.

If one operation has to wait for a slow external resource, the program may remain idle until that operation finishes.

Asynchronous programming provides a way to allow other work to progress while an asynchronous operation is waiting.

Simple idea

Async programming is less about "doing everything at exactly the same time" and more about efficiently managing work that spends time waiting.

This makes asynchronous programming especially valuable for I/O-bound applications.

03

Synchronous vs Asynchronous

Synchronous execution

def task_one():
    print("Task one")


def task_two():
    print("Task two")


task_one()
task_two()

The second function starts after the first function completes.

Asynchronous execution

import asyncio


async def task_one():
    await asyncio.sleep(2)
    print("Task one finished")


async def task_two():
    await asyncio.sleep(1)
    print("Task two finished")


async def main():

    await asyncio.gather(
        task_one(),
        task_two()
    )


asyncio.run(main())

Both coroutines can make progress during their waiting periods.

Important distinction

Async programming provides concurrency, not necessarily parallel execution on multiple CPU cores.

04

Blocking Operations

A blocking operation prevents the current execution flow from making progress until that operation finishes.

For example, a normal sleep call blocks the current thread:

import time

time.sleep(3)

In an asynchronous program, you normally use:

await asyncio.sleep(3)

Do not confuse them

Calling blocking functions directly inside an asynchronous event loop can prevent other async tasks from progressing efficiently.

05

Coroutines

A coroutine is an asynchronous function or coroutine object that can pause at an await point and later continue execution.

async def greet():

    print("Hello")

    await asyncio.sleep(1)

    print("Goodbye")

Calling an async function does not immediately execute the entire function like a normal function call.

result = greet()

The expression produces a coroutine object that must be awaited or scheduled for execution.

Think of a coroutine as a piece of asynchronous work that can pause and resume.

06

The async def Keyword

Use async def to define an asynchronous function.

async def download_data():

    print("Downloading data...")

    return "Data received"

An asynchronous function can contain one or more await expressions.

async def process():

    data = await download_data()

    print(data)

The await expression tells Python that the coroutine may need to pause while waiting for an asynchronous operation.

07

The await Keyword

await is used inside asynchronous code to wait for an awaitable operation to produce its result.

import asyncio


async def get_data():

    await asyncio.sleep(2)

    return "Data received"


async def main():

    result = await get_data()

    print(result)


asyncio.run(main())

Important rule

You cannot normally use await inside an ordinary synchronous function. It must be used in an appropriate asynchronous context.

08

The Event Loop

The event loop is a central part of Python's asynchronous execution model.

It manages asynchronous tasks and resumes them when their awaited operations are ready to continue.

Conceptual flow

  1. A coroutine starts running.
  2. It reaches an await.
  3. The coroutine pauses while waiting.
  4. The event loop can work on other ready tasks.
  5. The awaited operation becomes ready.
  6. The coroutine resumes.

You normally do not need to manually implement an event loop for everyday asyncio applications. High-level APIs such as asyncio.run() handle much of the setup for you.

09

The asyncio Module

Python's standard library includes the asyncio module for asynchronous programming.

import asyncio

It provides tools for working with:

  • Coroutines
  • Tasks
  • Event loops
  • Timers
  • Synchronization primitives
  • Concurrent asynchronous operations

One of the most common high-level functions is:

asyncio.run()
10

asyncio.run()

asyncio.run() is a convenient way to execute an asynchronous main coroutine.

import asyncio


async def main():

    print("Application started")

    await asyncio.sleep(1)

    print("Application finished")


asyncio.run(main())

The usual pattern is:

async def main():
    ...


asyncio.run(main())

This is an excellent starting pattern for standalone asyncio programs.

11

Creating Tasks

A task schedules a coroutine to run within the event loop.

import asyncio


async def work(name, seconds):

    await asyncio.sleep(seconds)

    print(name, "finished")


async def main():

    task1 = asyncio.create_task(
        work("Task 1", 2)
    )

    task2 = asyncio.create_task(
        work("Task 2", 1)
    )

    await task1
    await task2


asyncio.run(main())

Once scheduled, the tasks can make progress concurrently when they reach awaitable operations.

Why tasks matter

Tasks are useful when you want to start asynchronous operations and then wait for their results later.

12

asyncio.gather()

asyncio.gather() is a convenient way to execute multiple awaitable operations concurrently and collect their results.

import asyncio


async def get_price(product):

    await asyncio.sleep(1)

    return f"{product}: price received"


async def main():

    results = await asyncio.gather(
        get_price("Laptop"),
        get_price("Phone"),
        get_price("Tablet")
    )

    for result in results:
        print(result)


asyncio.run(main())

Instead of waiting for each operation independently, the three operations can overlap their waiting time.

Practical idea

This pattern can be useful when an application needs information from several independent services.

13

asyncio.sleep()

asyncio.sleep() suspends the current coroutine without blocking the event loop in the same way a normal blocking sleep would.

import asyncio


async def countdown():

    print("Starting...")

    await asyncio.sleep(2)

    print("Two seconds passed")


asyncio.run(countdown())

It is extremely useful for demonstrating asynchronous execution, timers, retry delays, and scheduling concepts.

14

Concurrency

Concurrency means that multiple tasks can make progress during overlapping periods.

Consider three network operations that each spend two seconds waiting for a response.

Running them strictly one after another could take around six seconds in a simplified example.

If their waiting periods can overlap asynchronously, the overall elapsed time may be closer to the slowest individual operation rather than their total.

The key lesson

Async programming can reduce wasted waiting time for suitable I/O-bound workloads.

But async is not magic

CPU-heavy calculations generally require a different strategy, such as multiprocessing, because asynchronous concurrency does not automatically provide parallel CPU execution.

15

Async I/O

Asynchronous programming is particularly useful for input/output operations.

Examples of I/O-bound work

  • HTTP API requests
  • Database queries
  • Network communication
  • Socket operations
  • Waiting for external services

The important principle is that the library performing the operation must support asynchronous usage if you want to await it directly.

async def fetch_data():

    response = await some_async_operation()

    return response

A normal synchronous library does not automatically become asynchronous just because you place it inside an async def function.

16

Exceptions in Async Code

Asynchronous functions can raise exceptions just like normal functions.

import asyncio


async def risky_operation():

    await asyncio.sleep(1)

    raise ValueError("Something went wrong")


async def main():

    try:

        await risky_operation()

    except ValueError as error:

        print("Error:", error)


asyncio.run(main())

You should handle exceptions at a level where the application can make an appropriate decision.

17

Timeouts

Network and external operations should not necessarily wait forever.

Python provides asynchronous timeout tools that allow applications to stop waiting after a defined period.

import asyncio


async def slow_operation():

    await asyncio.sleep(5)

    return "Finished"


async def main():

    try:

        async with asyncio.timeout(2):

            result = await slow_operation()

            print(result)

    except TimeoutError:

        print("Operation timed out")


asyncio.run(main())

Timeouts are important in production systems because external services can become slow or unavailable.

18

Task Cancellation

An asynchronous task can be cancelled when the application no longer needs its result.

import asyncio


async def long_task():

    try:

        await asyncio.sleep(10)

        print("Task completed")

    except asyncio.CancelledError:

        print("Task was cancelled")

        raise


async def main():

    task = asyncio.create_task(
        long_task()
    )

    await asyncio.sleep(1)

    task.cancel()

    try:

        await task

    except asyncio.CancelledError:

        print("Cancellation confirmed")


asyncio.run(main())

Cancellation is especially useful when requests disappear, timeouts occur, or a larger operation is no longer needed.

Cancellation should be handled carefully so that resources can be cleaned up properly.

19

Async Generators

Python also supports asynchronous generators using async def together with yield.

import asyncio


async def numbers():

    for number in range(1, 4):

        await asyncio.sleep(1)

        yield number


async def main():

    async for number in numbers():

        print(number)


asyncio.run(main())

Notice the use of async for rather than the normal for loop.

Where this helps

Async generators can be useful for processing or streaming asynchronous data one item at a time instead of loading everything into memory at once.

20

Async Python with FastAPI

In the previous lesson, you learned that FastAPI supports asynchronous endpoints.

from fastapi import FastAPI

app = FastAPI()


@app.get("/products")
async def products():

    return {
        "message": "Products loaded"
    }

Async endpoints become especially useful when the endpoint needs to await asynchronous I/O.

import asyncio

from fastapi import FastAPI

app = FastAPI()


@app.get("/report")
async def report():

    await asyncio.sleep(1)

    return {
        "status": "Report ready"
    }

Connecting the concepts

FastAPI + asynchronous libraries + async/await can provide an efficient architecture for APIs that spend significant time waiting for external resources.

21

Common Async Python Mistakes

Mistake 1 — Forgetting await

result = get_data()

This may produce a coroutine object instead of the actual result.

When appropriate, use:

result = await get_data()

Mistake 2 — Using time.sleep() in async code

time.sleep(5)

This blocks the current thread and can prevent other tasks from progressing.

For an asynchronous delay, use:

await asyncio.sleep(5)

Mistake 3 — Assuming async means parallel CPU execution

Async concurrency and CPU parallelism are different concepts. CPU-heavy work may require multiprocessing or another specialized strategy.

Mistake 4 — Making every function async

A function should be asynchronous when there is a meaningful reason to use asynchronous behavior. Adding async everywhere can make code unnecessarily complicated.

Mistake 5 — Calling blocking libraries from async code

A synchronous database or network library can still block execution even when called from an async def function.

22

Practice Exercises

Exercise 1 — First Coroutine

Create an async function called hello() that waits for one second and then prints a message.

Exercise 2 — asyncio.run()

Create a main() coroutine and run it using asyncio.run().

Exercise 3 — Multiple Tasks

Create three asynchronous tasks with different sleep durations and observe their completion order.

Exercise 4 — gather()

Use asyncio.gather() to run three simulated API calls concurrently.

Exercise 5 — Exception Handling

Create a coroutine that raises a ValueError and handle it using try/except.

Exercise 6 — Timeout

Create an operation that takes five seconds and cancel it using a two-second timeout.

Exercise 7 — Async Generator

Create an async generator that produces five numbers with a short delay between each number.

23

Mini Challenge — Async Ecommerce Data Collector

Build a small asynchronous program that simulates collecting information from multiple ecommerce services.

Your Tasks

  1. Create an async function called fetch_store_data().
  2. Give it a store name and delay as parameters.
  3. Use await asyncio.sleep() to simulate network waiting.
  4. Return a dictionary containing the store name and a simulated sales value.
  5. Create at least four store operations.
  6. Run them concurrently with asyncio.gather().
  7. Display the results.
  8. Calculate total sales.
  9. Add exception handling.
  10. Add a timeout to the overall operation.

Starter Code

import asyncio


async def fetch_store_data(store, delay, sales):

    await asyncio.sleep(delay)

    return {
        "store": store,
        "sales": sales
    }


async def main():

    results = await asyncio.gather(

        fetch_store_data(
            "Store A",
            2,
            1200
        ),

        fetch_store_data(
            "Store B",
            1,
            1800
        ),

        fetch_store_data(
            "Store C",
            3,
            950
        ),

        fetch_store_data(
            "Store D",
            1.5,
            2200
        )
    )

    print(results)


asyncio.run(main())

Challenge goal

Focus on understanding why the operations can overlap their waiting periods. The goal is not simply to use async because it is available.

24

Knowledge Check

Test your understanding of asynchronous Python.

1. What is asynchronous programming mainly useful for?

2. Which keyword defines an asynchronous function?

3. Which keyword waits for an asynchronous operation?

4. What is the event loop responsible for?

5. Which function is commonly used to run an async main coroutine?

6. What does asyncio.gather() help with?

7. Which operation can block an async program if used directly?

8. Does async automatically provide parallel CPU execution?

9. Which construct is useful for consuming an async generator?

10. Why can async programming improve an API server?

25

Lesson Summary

Asynchronous programming allows Python applications to efficiently manage tasks that spend significant time waiting for external operations.

Key concepts to remember

  • Synchronous code generally executes operations sequentially.
  • Async programming allows suitable tasks to make progress while other tasks are waiting.
  • async def defines asynchronous functions.
  • await waits for an awaitable operation.
  • Coroutines represent asynchronous units of work.
  • The event loop manages asynchronous execution.
  • asyncio.run() is a common entry point for standalone async programs.
  • asyncio.create_task() schedules work as a task.
  • asyncio.gather() can run multiple awaitables concurrently.
  • asyncio.sleep() provides a non-blocking asynchronous delay.
  • Async programming is particularly useful for I/O-bound workloads.
  • Async does not automatically make CPU-heavy calculations run in parallel.
  • Blocking synchronous operations can reduce the benefits of an async application.
  • FastAPI can use asynchronous endpoints for suitable workloads.

What's next?

In the next lesson, you will learn about Threading — another approach to concurrency in Python. We will compare threads with asynchronous programming and understand when threads are useful for I/O-bound work.

Ready for the next lesson?

Mark this lesson complete when you can explain async, await, coroutines, tasks, the event loop, and asyncio.gather(), and can identify situations where asynchronous programming is appropriate.