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 Python asyncio Event Loop Guide
Understand how Python's asyncio event loop schedules coroutines, what await actually does, and how to avoid the classic mistakes that turn async code into a tangle of bugs.
- Python asyncio Basics in Python
A practical introduction to Python asyncio: the event loop, async/await, asyncio.run, gather, create_task, and when to pick async over threads.
- FastAPI FastAPI Streaming Responses Tutorial
Stream large files, generated text, and Server-Sent Events from FastAPI without loading everything into memory.
- FastAPI FastAPI WebSockets Tutorial
Build real-time features with FastAPI WebSockets. Manage connections, broadcast messages, and handle disconnects cleanly.