Skip to main content

Python async and await: Mastering Coroutines

The async and await keywords are Python's elegant solution for writing asynchronous, concurrent code. Introduced in Python 3.5, they provide syntactic sugar that makes non-blocking code read almost like synchronous code. The async keyword transforms a regular function into a coroutine, while await pauses execution and yields control to the event loop, allowing other tasks to run. Together, they enable you to handle thousands of concurrent I/O operations with a clean, readable syntax.

Key Takeaways

  • async def creates a coroutine function; calling it returns a coroutine object without executing the body
  • await can only be used inside async functions and pauses the coroutine until an awaitable object completes
  • Awaitable objects include coroutines (other async functions), tasks (asyncio.create_task()), and futures
  • async/await code reads sequentially and linearly, making concurrency far easier to understand than callback-based patterns
  • While a coroutine is paused at an await point, the event loop runs other tasks, achieving concurrency without true parallelism
  • asyncio.run() is the entry point that creates an event loop, runs a coroutine, and cleans up

How Do You Define a Coroutine with async def?

A coroutine is a special function marked with the async keyword. Unlike regular functions, calling a coroutine does not execute its body—it returns a coroutine object that the event loop can manage.

# Regular function
def greet(name):
return f"Hello, {name}!"

# Coroutine function
async def greet_async(name):
return f"Hello, {name}!"

# Calling them
print(greet("Alice")) # Output: Hello, Alice!
print(greet_async("Bob")) # Output: <coroutine object greet_async at 0x...>

The second call prints a coroutine object, not the result. To execute a coroutine, you must either await it (inside another async function) or run it with asyncio.run().

import asyncio

async def main():
result = await greet_async("Charlie")
print(result) # Output: Hello, Charlie!

asyncio.run(main())

What Does await Do and When Can You Use It?

The await keyword pauses the current coroutine and waits for an awaitable object to complete. While waiting, the event loop is free to run other tasks, achieving concurrency.

import asyncio

async def fetch_data():
"""Simulate an I/O operation (e.g., a network request)."""
print("Fetching data...")
await asyncio.sleep(2) # Pause for 2 seconds
print("Data fetched!")
return {"id": 1, "name": "User"}

async def process_data():
"""Fetch data, then process it."""
result = await fetch_data()
print(f"Processing: {result}")

asyncio.run(process_data())

Output:

Fetching data...
Data fetched!
Processing: {'id': 1, 'name': 'User'}

The await fetch_data() line pauses the process_data coroutine until fetch_data completes. The key difference from synchronous code is that while waiting, the event loop can run other tasks.

Important: await can only be used inside an async function. Using await in regular code raises a SyntaxError.

What Objects Can You Await?

Three main types of objects are awaitable:

1. Coroutines — other async def functions:

async def task_a():
print("Task A starting")
await asyncio.sleep(1)
print("Task A done")
return "A result"

async def task_b():
print("Task B starting")
result = await task_a()
print(f"Task B got: {result}")

asyncio.run(task_b())

2. Tasks — coroutines wrapped with asyncio.create_task():

async def fetch_url(url):
await asyncio.sleep(1)
return f"Data from {url}"

async def main():
# Create tasks to run concurrently
task1 = asyncio.create_task(fetch_url("example.com"))
task2 = asyncio.create_task(fetch_url("google.com"))

# Wait for both
result1 = await task1
result2 = await task2

print(result1)
print(result2)

asyncio.run(main())

3. Futures — low-level objects representing eventual results (usually created by the event loop internally):

import asyncio

async def main():
# Create a future
future = asyncio.Future()

# Set the result after 1 second
await asyncio.sleep(1)
future.set_result("Done!")

# Await the future
result = await future
print(result)

asyncio.run(main())

How Do Async/Await Make Code Readable?

The real power of async/await is readability. Compare this to callback-based patterns or manual coroutine handling—async/await code reads linearly, like synchronous code.

# Traditional synchronous code (blocks execution)
def load_and_process():
data = fetch_data_sync() # Blocks for 2 seconds
result = process_data(data) # Then processes
return result

# Asynchronous code with async/await (non-blocking)
async def load_and_process_async():
data = await fetch_data() # Pauses without blocking
result = await process_data(data)
return result

# While load_and_process_async is paused, other tasks run

The async version reads identically to the sync version, but without blocking the entire program. This is the key advantage.

Complete Real-World Example: Concurrent Downloads

Here is a realistic example that fetches multiple URLs concurrently:

import asyncio
import time

async def download_page(url):
"""Simulate downloading a web page."""
print(f"Downloading {url}...")
await asyncio.sleep(2) # Simulate network delay
return f"Content from {url}"

async def main():
start = time.time()

# Create three download tasks
task1 = asyncio.create_task(download_page("https://example.com"))
task2 = asyncio.create_task(download_page("https://google.com"))
task3 = asyncio.create_task(download_page("https://github.com"))

# Wait for all to complete
result1 = await task1
result2 = await task2
result3 = await task3

elapsed = time.time() - start
print(f"\nResults:")
print(result1)
print(result2)
print(result3)
print(f"\nTotal time: {elapsed:.1f}s (would be 6s if sequential)")

asyncio.run(main())

Output:

Downloading https://example.com...
Downloading https://google.com...
Downloading https://github.com...

Results:
Content from https://example.com
Content from https://google.com
Content from https://github.com

Total time: 2.0s (would be 6s if sequential)

All three downloads happen in parallel. If they were sequential, it would take 6 seconds; concurrent execution takes only 2 seconds.

How Does Execution Flow with Async/Await?

Understanding the order of execution is crucial:

import asyncio

async def step1():
print("Step 1 start")
await asyncio.sleep(1)
print("Step 1 end")
return "result1"

async def step2():
print("Step 2 start")
await asyncio.sleep(1)
print("Step 2 end")
return "result2"

async def main():
print("Main start")

# Sequential: step1 completes, then step2
result1 = await step1()
result2 = await step2()

print(f"Results: {result1}, {result2}")
print("Main end")

asyncio.run(main())

Output:

Main start
Step 1 start
Step 1 end
Step 2 start
Step 2 end
Results: result1, result2
Main end

The tasks run sequentially. To run them concurrently, use asyncio.create_task():

async def main():
print("Main start")

# Concurrent: both start immediately
task1 = asyncio.create_task(step1())
task2 = asyncio.create_task(step2())

result1 = await task1
result2 = await task2

print(f"Results: {result1}, {result2}")
print("Main end")

asyncio.run(main())

Output (takes 2 seconds instead of 4):

Main start
Step 1 start
Step 2 start
Step 1 end
Step 2 end
Results: result1, result2
Main end

Frequently Asked Questions

Can I use await outside of an async function?

No, await only works inside an async def function. The entry point to run an async function is asyncio.run(my_coroutine()).

What is the difference between awaiting a coroutine directly vs. creating a task first?

Awaiting directly runs the coroutine to completion before continuing. Creating a task with asyncio.create_task() schedules it to run concurrently. The task runs in the background while your code continues.

Does async/await provide true parallelism?

No, it provides concurrency, not parallelism. Multiple I/O operations can be in progress simultaneously, but only one coroutine executes at a time (due to Python's GIL). For CPU-bound parallelism, use the multiprocessing module.

How do I handle exceptions in async code?

Use try/except blocks around await statements, just like synchronous code:

async def safe_fetch():
try:
result = await fetch_data()
except Exception as e:
print(f"Error: {e}")

Should I always use asyncio for I/O operations?

Asyncio is ideal for I/O-bound tasks (network requests, file operations, database queries). For CPU-bound work, use threads or processes instead.

Further Reading