Python async and await Basics
A gentle introduction to Python's async and await — coroutines, the event loop, awaiting tasks, asyncio.gather for concurrency, and when async actually helps.
What you'll learn
- ✓What a coroutine is and how async def differs from def
- ✓How the event loop schedules awaited work
- ✓Running coroutines concurrently with asyncio.gather
- ✓Why async helps with I/O but not CPU work
- ✓Avoiding the most common async pitfalls
Prerequisites
- •Basic Python familiarity
Async code in Python lets a single thread handle many slow operations at once — fetching ten URLs, reading from a database while waiting for a queue, serving thousands of WebSocket connections — without spawning a thread per task. The keywords are async and await, and the runtime is the asyncio event loop.
What async def Actually Does
A function defined with async def is a coroutine function. Calling it does not run the body. It returns a coroutine object that needs to be driven by an event loop.
import asyncio
async def hello():
print("hello")
coro = hello() # nothing prints yet
print(type(coro)) # <class 'coroutine'>
asyncio.run(hello()) # now it prints
asyncio.run creates an event loop, runs the coroutine to completion, and shuts the loop down. It is the typical entry point for an async program.
await: Pause and Yield Control
Inside a coroutine, await pauses execution until the awaited thing is done. While paused, the event loop can run other coroutines.
import asyncio
async def slow_task(name, seconds):
print(f"{name} starting")
await asyncio.sleep(seconds)
print(f"{name} done after {seconds}s")
async def main():
await slow_task("A", 1)
await slow_task("B", 1)
asyncio.run(main())
This runs sequentially — total time about two seconds — because each await waits for the previous task before starting the next. The async syntax alone doesn’t give you concurrency. You have to ask for it.
Running Coroutines Concurrently
asyncio.gather schedules multiple coroutines and waits for all of them to finish.
import asyncio
async def slow_task(name, seconds):
await asyncio.sleep(seconds)
return f"{name} done"
async def main():
results = await asyncio.gather(
slow_task("A", 1),
slow_task("B", 1),
slow_task("C", 1),
)
print(results)
asyncio.run(main())
Total wall-clock time is about one second, because all three tasks run concurrently. The event loop starts each one, and when they hit await asyncio.sleep, control returns to the loop, which moves on to the next ready coroutine.
A Realistic Example: Fetching URLs
This is where async earns its keep — many slow I/O operations at once.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = ["https://example.com", "https://example.org", "https://example.net"]
async with aiohttp.ClientSession() as session:
pages = await asyncio.gather(*(fetch(session, u) for u in urls))
for url, page in zip(urls, pages):
print(url, len(page))
asyncio.run(main())
Three HTTP requests in parallel, one thread, no callback spaghetti. Compare this with synchronous requests calls, which would block the thread for the full sum of latencies.
Tasks for Fire-and-Forget Work
If you want a coroutine to start running without waiting for it immediately, wrap it in a task.
async def main():
task = asyncio.create_task(slow_task("background", 2))
await slow_task("foreground", 1)
result = await task # join the background task
A task starts as soon as the event loop is free, regardless of whether you have awaited it yet. Awaiting the task later collects its return value.
Async Doesn’t Speed Up CPU Work
async doesn’t make computation faster. If your coroutines do heavy CPU work without awaiting, the event loop is blocked just like a normal thread.
async def heavy():
total = 0
for i in range(10_000_000):
total += i # never awaits — blocks the loop
return total
For CPU-bound work, use processes (multiprocessing or concurrent.futures.ProcessPoolExecutor). Async is for I/O-bound work — anything where you are waiting on the network, the disk, or another process.
If you absolutely must run a blocking call in an async context, push it to a thread pool.
import asyncio
def blocking():
# imagine a sync library call
return 42
async def main():
result = await asyncio.to_thread(blocking)
print(result)
Common Pitfalls
Forgetting to await a coroutine is the classic mistake. The coroutine is created but never driven, and Python warns you with RuntimeWarning: coroutine was never awaited. Always look at that warning carefully.
Mixing blocking and async code is another. A single synchronous time.sleep(5) inside an async program freezes every other coroutine for five seconds. Use await asyncio.sleep(5) instead.
Sharing state across coroutines does not need locks the way threads do — coroutines only switch at await points, so non-await sections are atomic — but be careful around partially updated state when you do await.
When to Reach for async
Use async when you have many concurrent I/O operations: web servers, scrapers, chat services, message-queue consumers. Use threads for a small number of blocking operations in legacy libraries. Use processes for CPU-bound work.
If your program does one HTTP call at a time and exits, async is overkill — plain requests will do.
Wrapping Up
async and await look like new syntax but they describe a familiar idea: cooperative multitasking around I/O. Start small — one program with a couple of gather calls — and the model clicks fast. From there, async opens the door to networking code that would be painfully complex with threads.
Related articles
- Python Advanced asyncio Patterns for Production Python
Go beyond basic async/await with structured concurrency, task groups, semaphores, cancellation, and error handling patterns in Python asyncio.
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Concurrency: Threading vs Multiprocessing vs Asyncio
Compare Python's concurrency models -- threading, multiprocessing, and asyncio -- with practical examples and performance guidance.
- Python Python asyncio Tutorial
Learn Python's asyncio — coroutines, tasks, event loops, async generators, and building concurrent I/O-bound applications.