As modern applications become increasingly complex and interconnected, the need to efficiently handle operations that take time — like network requests, file I/O, or database queries — is more crucial than ever. Python’s async/await syntax offers an elegant and powerful way to write asynchronous code that is both readable and efficient. In this lesson, we'll journey through the foundations and practical usage of async and await, empowering you to write non-blocking Python programs with ease.
Why Async? Understanding the Problem
Imagine a program that needs to download data from multiple websites. If it processes each download one by one, it must wait for each request to complete before moving on. This waiting time blocks the program and wastes valuable resources.
💡 Think of async/await like a smart multitasker
Instead of waiting idly for one task to finish, your program can "pause" that task and jump to others, making better use of time and resources.
Traditional synchronous code is simple but can be inefficient for I/O-bound tasks. Python’s asyncio library and the async/await syntax provide a way to write asynchronous code that looks and feels like normal, sequential Python.
Introducing async and await
The async keyword is used to declare a function as asynchronous, which means it returns a coroutine — a special object that can be paused and resumed.
The await keyword is used inside async functions to pause execution until the awaited coroutine completes, without blocking the entire program.
Basic Syntax
Here’s a minimal example to demonstrate the syntax:
📌 Deep Dive: Minimal async/await Example
import asyncio
async def greet():
print("Hello")
await asyncio.sleep(1) # Simulates an async delay
print("World")
asyncio.run(greet())
asyncio.sleep(1) is an asynchronous non-blocking sleep function that pauses the greet coroutine for 1 second without stopping the entire program.
How It Works Under the Hood
When you declare a function with async def, calling it doesn’t run the function immediately. Instead, it returns a coroutine object. This coroutine must be scheduled and awaited to actually execute.
The await keyword pauses the coroutine until the awaited task completes, giving control back to the event loop, which can then run other coroutines.

Understanding Coroutines vs. Normal Functions
It’s important to distinguish between regular functions and coroutines:
| Normal Function | Coroutine (async function) |
|---|---|
| Runs immediately when called | Returns a coroutine object when called |
| Executes sequentially and blocks | Can be paused and resumed, enabling concurrency |
Uses return to produce results | Uses await to pause execution |
| Cannot be awaited | Must be awaited or run by event loop |
Running Async Code: Event Loop & asyncio
To execute async functions, you need an event loop — a programming construct that manages and schedules when coroutines run. Python’s built-in asyncio module provides an event loop and utilities to work with asynchronous code.
The simplest way to run a coroutine is using asyncio.run(), which handles the event loop automatically:
📌 Deep Dive: Running Coroutines with asyncio.run()
import asyncio
async def main():
print("Start")
await asyncio.sleep(2)
print("End")
asyncio.run(main())
Chaining Multiple Async Calls
Often, you’ll call multiple async functions and await them in sequence or concurrently.
Sequential awaiting:
📌 Deep Dive: Sequential Async Calls
import asyncio
async def task1():
await asyncio.sleep(1)
return "Result 1"
async def task2():
await asyncio.sleep(2)
return "Result 2"
async def main():
r1 = await task1()
r2 = await task2()
print(r1, r2)
asyncio.run(main())
Sequential awaiting waits for each task to finish before starting the next. This can be inefficient if tasks are independent.
Concurrent Execution with asyncio.gather()
To run multiple coroutines concurrently, use asyncio.gather():
📌 Deep Dive: Concurrent Async Calls
import asyncio
async def task1():
await asyncio.sleep(1)
return "Result 1"
async def task2():
await asyncio.sleep(2)
return "Result 2"
async def main():
results = await asyncio.gather(task1(), task2())
print(results)
asyncio.run(main())
This runs task1 and task2 concurrently, completing in about 2 seconds total instead of 3 seconds sequentially.
💡 Key Concept: Awaiting vs. Running
Using await pauses the current coroutine until the awaited coroutine finishes, but it does not block the entire program thanks to the event loop.
Common Pitfalls and How to Avoid Them
⚠️ Forgetting to Await
Calling an async function without await returns a coroutine object but does not run it. This often leads to unexpected behavior or warnings.
For example:
📌 Deep Dive: Missing await Example
import asyncio
async def say_hello():
print("Hello")
async def main():
say_hello() # Missing await here!
asyncio.run(main())
No output is produced because say_hello() was never awaited or run.
⚠️ Using Blocking Code Inside Async Functions
Calling blocking functions (like time.sleep()) inside async functions will block the event loop, defeating the purpose of async.
Always use their async counterparts (e.g., asyncio.sleep()) to avoid blocking.
Mixing Sync and Async Code
If your program contains both synchronous and asynchronous functions, careful integration is key. You can call async functions from synchronous code using asyncio.run(), but you cannot call async functions directly from synchronous functions without this.
Inside async functions, you can call synchronous functions without issue.
Practical Example: Fetching Multiple Webpages
Let’s see async/await in action with an I/O-bound task: fetching multiple webpages concurrently using aiohttp, an asynchronous HTTP client library.
📌 Deep Dive: Async Web Requests with aiohttp
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
"https://example.com",
"https://httpbin.org/get",
"https://api.github.com"
]
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
pages = await asyncio.gather(*tasks)
for i, content in enumerate(pages):
print(f"Page {i+1} length: {len(content)} characters")
asyncio.run(main())
This example creates multiple coroutines to fetch webpages concurrently, dramatically speeding up total runtime compared to sequential requests.
Summary and Best Practices
- Use
async defto declare asynchronous functions. - Inside async functions, use
awaitto pause execution until the awaited coroutine completes. - Use
asyncio.run()to start and run your async program. - Use
asyncio.gather()to run multiple coroutines concurrently. - Avoid blocking calls inside async functions; use async-friendly alternatives.
- Remember: calling an async function without
awaitdoes NOT run it.
💡 Final Thought
Mastering async/await lets you write Python code that can handle many slow operations efficiently and elegantly, making your programs faster and more responsive.
Quick Knowledge Check
Test what you just learned
Question 1 of 2
What happens when you call an async function without using await?
Question 2 of 2
Which of the following should you use to run multiple async tasks concurrently?
Loading results...