ZetCode

Python async/await

last modified October 30, 2026 Python 3.15

In this article we show how to use the async/await keywords in Python.

With asynchronous programming, we can run many tasks concurrently in a single thread. While one task waits for a network response, a timer, or a database, other tasks can run. The async and await keywords make asynchronous code look almost like regular sequential code.

Python coroutine

A coroutine is a function that can be paused and resumed. Coroutines are used in cooperative multitasking: a coroutine runs until it voluntarily gives up control at an await expression. A coroutine is declared with the async def syntax.

The async/await keywords

The async/await syntax was introduced in Python 3.5 (PEP 492); since Python 3.7, async and await are reserved keywords. The async keyword is used to create a coroutine. The await keyword suspends the execution of the current coroutine until the awaited object completes and returns its result. The await keyword can only be used inside an async function.

Coroutines need an event loop to run. The event loop is provided by the asyncio module of the standard library; see Python asyncio for more details on the module.

Python async/await first example

The following is a simple program which uses the async keyword.

first_coroutine.py
import asyncio


async def mul(x, y):
    return x * y


coro = mul(5, 5)
print(type(coro))

res = asyncio.run(coro)
print(res)

The program creates and runs a coroutine.

async def mul(x, y):
    return x * y

A coroutine function is a function declared with the async modifier.

coro = mul(5, 5)
print(type(coro))

Calling a coroutine function does not run its body. It returns a coroutine object.

res = asyncio.run(coro)

The asyncio.run function creates an event loop, runs the coroutine until it completes, closes the loop, and returns the result. It is the usual entry point of an asynchronous program.

$ python first_coroutine.py
<class 'coroutine'>
25

Awaiting coroutines

Inside a coroutine, we run other coroutines with await.

await_coroutines.py
import asyncio


async def add(x, y):
    return x + y


async def get_results():
    res1 = await add(3, 4)
    res2 = await add(8, 5)

    print(res1, res2)


asyncio.run(get_results())

The get_results coroutine awaits two calls to add. Each await returns the value returned by the coroutine. The second call starts only after the first one completes.

$ python await_coroutines.py
7 13

Sequential vs concurrent execution

Awaiting coroutines one after another runs them sequentially. To run them concurrently, we use asyncio.gather. With asyncio.sleep, we create a coroutine that finishes after the specified number of seconds; it simulates a long-running task.

sequential_concurrent.py
import asyncio
import time


async def task(tid, n):
    await asyncio.sleep(n)
    print(f'task {tid} finished after {n} s')


async def sequential():
    await task(1, 3)
    await task(2, 2)
    await task(3, 1)


async def concurrent():
    await asyncio.gather(task(1, 3), task(2, 2), task(3, 1))


for main in (sequential, concurrent):
    t1 = time.perf_counter()
    asyncio.run(main())
    t2 = time.perf_counter()
    print(f'{main.__name__}: {t2 - t1:0.2f} seconds')

We run three tasks that take three, two, and one second.

async def sequential():
    await task(1, 3)
    await task(2, 2)
    await task(3, 1)

Each task starts after the previous one completes. The total time is the sum of the individual times.

async def concurrent():
    await asyncio.gather(task(1, 3), task(2, 2), task(3, 1))

The gather function runs all three coroutines at the same time. While one task sleeps, the event loop runs the others. The total time is the time of the longest task, and the tasks finish in the order of their durations.

$ python sequential_concurrent.py
task 1 finished after 3 s
task 2 finished after 2 s
task 3 finished after 1 s
sequential: 6.01 seconds
task 3 finished after 1 s
task 2 finished after 2 s
task 1 finished after 3 s
concurrent: 3.00 seconds

Results of gather

The gather function returns a list of results. The results are in the order of the arguments, not in the order in which the coroutines finished.

gather_results.py
import asyncio


async def fetch_price(product, delay):
    await asyncio.sleep(delay)
    print(f'got {product}')
    return f'{product}: {delay * 10:.2f} EUR'


async def main():
    prices = await asyncio.gather(
        fetch_price('apples', 0.3),
        fetch_price('pears', 0.1),
        fetch_price('plums', 0.2),
    )
    print(prices)


asyncio.run(main())

We simulate fetching three prices with different delays. The pears arrive first, but the list of prices keeps the order of the calls.

$ python gather_results.py
got pears
got plums
got apples
['apples: 3.00 EUR', 'pears: 1.00 EUR', 'plums: 2.00 EUR']

Creating tasks

The asyncio.create_task function schedules a coroutine to run in the background and returns a task object. The current coroutine can do other work and await the task later.

create_task.py
import asyncio
import time


async def download(name, n):
    print(f'{name}: started')
    await asyncio.sleep(n)
    print(f'{name}: done')
    return n


async def main():
    t1 = time.perf_counter()

    task = asyncio.create_task(download('report', 2))

    for i in range(3):
        print(f'main: working {i}')
        await asyncio.sleep(0.5)

    print(f'task done: {task.done()}')

    res = await task
    print(f'result: {res}, elapsed: {time.perf_counter() - t1:0.2f} s')


asyncio.run(main())

We start a download task and then do some work in the main coroutine.

task = asyncio.create_task(download('report', 2))

The task does not start immediately; it starts when main gives up control at its first await.

res = await task

Awaiting the task returns its result. Since the download ran while main was working, the total time is two seconds, not three and a half.

$ python create_task.py
main: working 0
report: started
main: working 1
main: working 2
task done: False
report: done
result: 2, elapsed: 2.00 s

Task groups

The asyncio.TaskGroup (Python 3.11) is the recommended way to run a group of tasks. The async with block waits for all tasks to finish. If any task fails, the remaining tasks are cancelled and the errors are raised as an ExceptionGroup.

task_group.py
import asyncio


async def square(n):
    await asyncio.sleep(n / 10)
    return n * n


async def failing(n):
    await asyncio.sleep(n / 10)
    raise ValueError(f'cannot process {n}')


async def main():
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(square(n)) for n in range(1, 6)]

    print([task.result() for task in tasks])

    try:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(square(n)) for n in range(1, 6)]
            tg.create_task(failing(2))
    except* ValueError as eg:
        print(f'errors: {eg.exceptions}')

    print([task.cancelled() for task in tasks])


asyncio.run(main())

In the first group, all tasks succeed and we read their results with the result method.

try:
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(square(n)) for n in range(1, 6)]
        tg.create_task(failing(2))
except* ValueError as eg:
    print(f'errors: {eg.exceptions}')

In the second group, one task fails after 0.2 seconds. The tasks that have not finished by then are cancelled. The except* clause handles the exceptions from the group.

$ python task_group.py
[1, 4, 9, 16, 25]
errors: (ValueError('cannot process 2'),)
[False, False, True, True, True]

Cancelling a task group

Python 3.15 adds the TaskGroup.cancel method. It cancels all unfinished tasks of the group, and the async with block exits without raising an exception. A typical use is to stop when the first result is available.

first_result.py
import asyncio


async def query(mirror, delay, results, tg):
    await asyncio.sleep(delay)
    results.append(mirror)
    print(f'{mirror} answered after {delay} s')
    tg.cancel()


async def main():
    results = []

    try:
        async with asyncio.TaskGroup() as tg:
            for mirror, delay in [('eu', 0.3), ('us', 0.1), ('asia', 0.2)]:
                tg.create_task(query(mirror, delay, results, tg))
    except* AttributeError as eg:
        print(f'AttributeError: {eg.exceptions[0]}')

    print(f'answers: {results}')


asyncio.run(main())

We query three mirrors and keep the first answer.

results.append(mirror)
print(f'{mirror} answered after {delay} s')
tg.cancel()

The fastest task cancels the group, so the slower mirrors never answer.

except* AttributeError as eg:
    print(f'AttributeError: {eg.exceptions[0]}')

We run the program with Python 3.15 and with Python 3.14. On 3.14, the method does not exist; the AttributeError raised inside the task makes the group cancel the other tasks and is reported in an exception group.

$ python first_result.py
us answered after 0.1 s
answers: ['us']
$ python3.14 first_result.py
us answered after 0.1 s
AttributeError: 'TaskGroup' object has no attribute 'cancel'
answers: ['us']

Handling exceptions in gather

By default, gather raises the first exception that occurs in any of the coroutines. With return_exceptions=True, exceptions are returned in the list of results.

gather_exceptions.py
import asyncio


async def parse(text):
    await asyncio.sleep(0.1)
    return int(text)


async def main():
    data = ['12', '7', 'x', '42']

    try:
        await asyncio.gather(*(parse(item) for item in data))
    except ValueError as e:
        print(f'gather failed: {e}')

    res = await asyncio.gather(*(parse(item) for item in data),
                               return_exceptions=True)
    print(res)


asyncio.run(main())

We parse four strings into integers; one of them is invalid.

res = await asyncio.gather(*(parse(item) for item in data),
                           return_exceptions=True)

The exception object takes the place of the failed result, and the other results are kept.

$ python gather_exceptions.py
gather failed: invalid literal for int() with base 10: 'x'
[12, 7, ValueError("invalid literal for int() with base 10: 'x'"), 42]

Timeouts

The asyncio.timeout context manager (Python 3.11) cancels the code inside the block if it takes too long, and raises a TimeoutError. The asyncio.wait_for function does the same for a single awaitable.

timeouts.py
import asyncio


async def slow_operation():
    await asyncio.sleep(5)
    return 'finished'


async def main():
    try:
        async with asyncio.timeout(1):
            res = await slow_operation()
            print(res)
    except TimeoutError:
        print('the operation timed out')

    try:
        res = await asyncio.wait_for(slow_operation(), timeout=0.5)
    except TimeoutError:
        print('wait_for timed out')


asyncio.run(main())

The slow_operation coroutine takes five seconds, but we allow it only one second, and half a second the second time.

$ python timeouts.py
the operation timed out
wait_for timed out

Asynchronous generators and async for

An async def function that contains yield is an asynchronous generator. We iterate over it with async for. Asynchronous comprehensions use async for as well.

async_generator.py
import asyncio


async def ticker(n, delay):
    for i in range(n):
        await asyncio.sleep(delay)
        yield i


async def main():
    async for tick in ticker(3, 0.2):
        print(f'tick {tick}')

    squares = [tick * tick async for tick in ticker(5, 0.1)]
    print(squares)


asyncio.run(main())

The ticker generator awaits between the values it yields.

squares = [tick * tick async for tick in ticker(5, 0.1)]

An asynchronous list comprehension collects the values into a list.

$ python async_generator.py
tick 0
tick 1
tick 2
[0, 1, 4, 9, 16]

Asynchronous context managers and async with

The async with statement works with asynchronous context managers, whose setup and cleanup can await. A class implements the __aenter__ and __aexit__ methods; the asynccontextmanager decorator creates one from an asynchronous generator.

async_context.py
import asyncio
from contextlib import asynccontextmanager


class Connection:

    async def __aenter__(self):
        await asyncio.sleep(0.1)
        print('connected')
        return self

    async def __aexit__(self, exc_type, exc, tb):
        await asyncio.sleep(0.1)
        print('disconnected')

    async def query(self, sql):
        await asyncio.sleep(0.1)
        return f'result of {sql!r}'


@asynccontextmanager
async def transaction(con):
    print('begin')
    yield con
    print('commit')


async def main():
    async with Connection() as con:
        async with transaction(con):
            print(await con.query('SELECT 1'))


asyncio.run(main())

The Connection class simulates connecting to and disconnecting from a database.

@asynccontextmanager
async def transaction(con):
    print('begin')
    yield con
    print('commit')

The code before yield runs when the block is entered, the code after it when the block is exited.

$ python async_context.py
connected
begin
result of 'SELECT 1'
commit
disconnected

Blocking code in coroutines

The event loop can switch tasks only at await expressions. A blocking call, such as time.sleep or a synchronous HTTP request, stops all tasks. The asyncio.to_thread function runs blocking code in a separate thread.

blocking.py
import asyncio
import time


def blocking_io(n):
    time.sleep(n)
    return n


async def heartbeat():
    for _ in range(4):
        print(f'heartbeat {time.perf_counter() - start:0.1f}')
        await asyncio.sleep(0.5)


async def bad():
    blocking_io(1.5)


async def good():
    await asyncio.to_thread(blocking_io, 1.5)


async def main(worker):
    await asyncio.gather(heartbeat(), worker())


for worker in (bad, good):
    print(f'--- {worker.__name__}')
    start = time.perf_counter()
    asyncio.run(main(worker))

The heartbeat coroutine prints the elapsed time every half second while a worker runs.

async def bad():
    blocking_io(1.5)

The bad worker calls the blocking function directly. The heartbeat stops for one and a half seconds.

async def good():
    await asyncio.to_thread(blocking_io, 1.5)

The good worker runs the function in a thread, and the heartbeat keeps ticking.

$ python blocking.py
--- bad
heartbeat 0.0
heartbeat 1.5
heartbeat 2.0
heartbeat 2.5
--- good
heartbeat 0.0
heartbeat 0.5
heartbeat 1.0
heartbeat 1.5

Python Playwright async example

Many libraries have support for asynchronous programming. The Playwright library allows to automate browsers both in synchronous and asynchronous modes. It is installed with pip install playwright, and the browsers with playwright install.

page_title.py
import asyncio

from playwright.async_api import async_playwright


async def get_title(browser, url):
    page = await browser.new_page()
    await page.goto(url)
    title = await page.title()
    await page.close()
    return title


async def main():
    urls = ['https://example.com', 'https://www.python.org',
            'https://docs.python.org/3/']

    async with async_playwright() as playwright:
        browser = await playwright.chromium.launch()

        titles = await asyncio.gather(*(get_title(browser, url)
                                        for url in urls))

        for url, title in zip(urls, titles):
            print(f'{url}: {title}')

        await browser.close()


asyncio.run(main())

In the example, we get the titles of three web pages with Playwright. We use the async API.

titles = await asyncio.gather(*(get_title(browser, url)
                                for url in urls))

Each page is opened in a new tab of the same browser, and the pages are loaded concurrently. See Python Playwright.

$ python page_title.py
https://example.com: Example Domain
https://www.python.org: Welcome to Python.org
https://docs.python.org/3/: Python 3.15.2 documentation

Source

Coroutines and tasks - Python documentation

In this article we have worked with async/await keywords in Python.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.