Skip to content
C Codeloom
Python

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.

·5 min read · By Codeloom
Beginner 10 min read

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.