Part of Python for Beginners

Async/Await - Concurrent I/O Without Blocking (Python Tutorial #31)

Sandy LaneSandy Lane

Video: Async/Await - Concurrent I/O Without Blocking (Python Tutorial #31) by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python Async/Await: asyncio for I/O-bound concurrency

async def defines a coroutine. await pauses until an awaitable resolves. asyncio.run(main()) runs the event loop. asyncio.gather(...) runs multiple coroutines concurrently. Async is for I/O-bound work — multiple network calls, file reads — not CPU-bound work (use threads/processes for that).

When your program waits on the network or disk, it's idle. Async lets you start the next operation while the previous waits — concurrency without threads.

Sync vs async

import time

def fetch_sync(name, delay):
  print(f"Fetching {name}...")
  time.sleep(delay)
  return f"{name} done"

start = time.time()
r1 = fetch_sync("acme.com", 1.0)
r2 = fetch_sync("globex.com", 1.0)
print(f"Took {time.time() - start:.2f}s")
# Took 2.00s — sequential

Two 1-second waits → 2 seconds total. Each blocks before the next starts.

import asyncio

async def fetch_async(name, delay):
  print(f"Fetching {name}...")
  await asyncio.sleep(delay)
  return f"{name} done"

async def main():
  start = time.time()
  r1, r2 = await asyncio.gather(
    fetch_async("acme.com", 1.0),
    fetch_async("globex.com", 1.0)
  )
  print(f"Took {time.time() - start:.2f}s")

asyncio.run(main())
# Took 1.00s — concurrent

Both fetches start, both wait, both resume. The total time is the maximum of any one operation, not the sum.

async def: defining a coroutine

async def my_function():
  return 42

async def makes a coroutine function. Calling it returns a coroutine object — it doesn't run yet.

coro = my_function()      # NOT executed
print(coro)               # <coroutine object my_function at ...>
result = await coro       # NOW it runs (only inside another async function)

await: wait for the result

await pauses the current coroutine until the awaited thing finishes. While paused, the event loop runs other coroutines.

You can await:

  • Coroutines (async def function calls).
  • asyncio.Task and Future objects.
  • Anything implementing __await__.

You can NOT use await outside an async def. (Except in REPL or top-level scripts via asyncio.run.)

asyncio.run(): the entry point

async def main():
  await something()

asyncio.run(main())

asyncio.run() creates an event loop, runs the coroutine to completion, then closes the loop. Use it once at the top of your program.

In Jupyter, the loop is already running — just await main() directly.

asyncio.gather: concurrent execution

results = await asyncio.gather(
  fetch_async("acme.com", 1.0),
  fetch_async("globex.com", 1.0),
  fetch_async("initech.com", 1.0)
)
# results is a list in the same order as the inputs

gather schedules all coroutines and waits for all to complete. Returns results in the same order as inputs.

If any coroutine raises, gather raises immediately and cancels the rest. To collect errors instead:

results = await asyncio.gather(
  ...,
  return_exceptions=True
)

for r in results:
  if isinstance(r, Exception):
    print(f"Failed: {r}")
  else:
    print(f"Got: {r}")

asyncio.create_task: background tasks

async def main():
  task1 = asyncio.create_task(process_order(101, 1.0))
  task2 = asyncio.create_task(process_order(102, 0.8))
  # Both running now in the background

  # Do other work...

  results = await asyncio.gather(task1, task2)

create_task schedules a coroutine to run immediately in the background. Returns a Task (which is a Future). You can await it later, or cancel() it.

gather is a higher-level convenience: it wraps coroutines in tasks for you.

Timeouts: asyncio.wait_for

try:
  result = await asyncio.wait_for(
    slow_fetch("initech.com", 3.0),
    timeout=1.0
  )
except asyncio.TimeoutError:
  print("Took too long")

If the coroutine doesn't finish within timeout seconds, it's cancelled and TimeoutError is raised.

In Python 3.11+, asyncio.timeout() is the modern context-manager form:

async with asyncio.timeout(1.0):
  result = await slow_fetch("initech.com", 3.0)

When async helps (and when it doesn't)

Async helps for I/O-bound work:

  • Multiple HTTP requests.
  • Database queries.
  • File reads/writes.
  • Anything that spends most time waiting.

Async does NOT help for CPU-bound work:

  • Number crunching.
  • Image processing.
  • Compression.

Why? Async is single-threaded — only one coroutine runs at a time. While CPU is busy in one coroutine, others are blocked. For CPU work, use multiprocessing or concurrent.futures.ProcessPoolExecutor.

A sync call in async = the kiss of death

async def bad():
  time.sleep(5)        # blocks the event loop! everything else stops.
  await fetch_data()

Inside an async function, never call blocking sync code (time.sleep, requests.get, open().read() for big files). Use the async equivalent (asyncio.sleep, aiohttp.get, aiofiles.open).

If you must run sync code, use asyncio.to_thread:

async def good():
  await asyncio.to_thread(blocking_function, arg1, arg2)

This runs the sync function in a thread pool, freeing the event loop.

Async libraries

For async to be useful, your I/O library must be async-aware:

  • aiohttp — async HTTP (replaces requests).
  • httpx — supports both sync and async.
  • aiofiles — async file I/O.
  • asyncpg — PostgreSQL.
  • motor — MongoDB.
  • databases — generic async DB.

Mix sync and async carefully. Better: pick a stack that's async all the way down.

Async iteration

async def fetch_pages():
  for url in urls:
    yield await fetch(url)

async for page in fetch_pages():
  process(page)

async for iterates an async iterator. async def with yield defines an async generator.

Async context manager

async with aiohttp.ClientSession() as session:
  async with session.get("https://example.com") as r:
    text = await r.text()

async with calls __aenter__ / __aexit__ (the async equivalents of __enter__/__exit__). For resources that need async setup or teardown.

Common patterns

# Run with concurrency limit
async def fetch_all_throttled(urls, max_concurrent=5):
  sem = asyncio.Semaphore(max_concurrent)

  async def with_sem(url):
    async with sem:
      return await fetch(url)

  return await asyncio.gather(*[with_sem(u) for u in urls])

A semaphore limits concurrent operations — useful for rate-limited APIs.

Common stumbles

Calling an async function without await. Returns a coroutine object, doesn't run. Add await.

await outside async def. SyntaxError. Wrap in an async function or use asyncio.run.

Forgetting asyncio.run. await main() in a script crashes — there's no event loop.

time.sleep inside async. Blocks everything. Use asyncio.sleep.

Synchronous library inside async. Same problem — use the async version, or asyncio.to_thread.

Forgetting to await gather/create_task. Coroutine warnings, no concurrency. Always await.

CPU-heavy code in async. Other coroutines starve. Use a process pool for CPU work.

Catching all exceptions including CancelledError. Tasks need to be cancellable. Catch Exception, not BaseException.

What's next

Lesson 32: HTTP requests with the requests library. GET, POST, headers, JSON, query params, sessions.

Recap

async def defines a coroutine; await pauses until done. asyncio.run(main()) is the entry point. asyncio.gather(...) runs many coroutines concurrently; asyncio.create_task(...) schedules in the background. Async is for I/O-bound work — calling APIs, reading files. Don't call blocking sync code inside async; use async-aware libraries (aiohttp, aiofiles) or asyncio.to_thread. Use asyncio.wait_for or asyncio.timeout for limits.

Next lesson: HTTP requests with requests.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.