Skip to main content
Intermediate Python2026-07-29 · 11 min read

Python async / await Explained: The 2026 Tutorial

Async in Python has a reputation for being confusing. It doesn't have to be. Once you internalise one sentence — async lets ONE thread run multiple I/O-bound tasks interleaved — everything else follows.

This tutorial covers: what async def and await actually do, when async wins vs when it's the wrong tool, the four patterns every async codebase uses (sequential, gather, semaphore, timeout), and the three ways beginners silently swallow errors.

The one-sentence mental model

Async in Python is cooperative concurrency in a single thread. When your code hits await, control returns to the event loop, which can run another task while the first one is waiting for I/O. When the I/O completes, the loop resumes the first task from where it paused.

That's it. No threads, no processes, no parallelism (in the CPython-with-GIL sense). Just one thread juggling many tasks that spend most of their time waiting on network / disk / database.

Async is faster when your bottleneck is waiting, not computing.

The three-liner minimum viable async program

▶ PYTHONeditable · runs in your browser
  • async def hello() — declares a coroutine. Calling hello() doesn't run the code; it creates a coroutine object.
  • await asyncio.sleep(1) — yields control back to the event loop for 1 second.
  • asyncio.run(hello()) — creates an event loop and runs the coroutine to completion.

Rule: `await` only inside `async def`. Calling await in a regular function is a SyntaxError.

When async wins (and when it doesn't)

Async wins: I/O-bound work

Any task that spends most of its wall-clock time WAITING is a candidate for async:

  • HTTP requests (external APIs, scraping, webhooks)
  • Database queries
  • File reads / writes
  • WebSocket / gRPC streams
  • Queue consumers (Redis, RabbitMQ, Kafka)

Example: fetching 100 URLs.

▶ PYTHONeditable · runs in your browser

50-100x wall-clock speedup for I/O-heavy work. This is where async earns its complexity budget.

Async LOSES: CPU-bound work

Async gives you no parallelism for computation. Since it's cooperative single-threaded, a CPU-hot loop blocks the entire event loop.

▶ PYTHONeditable · runs in your browser

All other tasks starve while pbkdf2 runs. For CPU-bound work, reach for multiprocessing or concurrent.futures.ProcessPoolExecutor, not async.

Mixed workload: run_in_executor

If ONE step is CPU-heavy and the rest is I/O, wrap the CPU step so it runs in a thread pool without blocking the loop:

▶ PYTHONeditable · runs in your browser

The None = default thread pool. Great for calling third-party sync libraries that are slow but you can't rewrite.

The four patterns

1. Sequential — one task at a time

▶ PYTHONeditable · runs in your browser

Use when each request depends on the previous one's result.

2. asyncio.gather — fire all at once

▶ PYTHONeditable · runs in your browser

Default choice for independent I/O tasks. But: if you gather 10,000 tasks against one API, you'll get rate-limited or exhaust file descriptors. That's where the semaphore pattern comes in.

3. Semaphore — cap concurrency

▶ PYTHONeditable · runs in your browser

asyncio.Semaphore(N) lets at most N tasks past the async with sem: at any moment. The other tasks queue. Use for any external API with a rate limit or a database with a connection pool.

4. Timeout — abandon slow work

▶ PYTHONeditable · runs in your browser

asyncio.wait_for cancels the underlying task after N seconds. Essential for any async code that talks to a network — never make an unbounded await.

The three error-swallowing traps

Trap 1: asyncio.gather with return_exceptions=True

▶ PYTHONeditable · runs in your browser

Helpful when you want to keep partial results but LETHAL if you don't check every result:

▶ PYTHONeditable · runs in your browser

Without the check, the errors silently vanish.

Trap 2: Fire-and-forget tasks

▶ PYTHONeditable · runs in your browser

If background_work() raises, Python emits a warning at shutdown and moves on. For anything you care about, store the task and await it or use asyncio.TaskGroup (3.11+) which propagates all errors.

Trap 3: Sync code inside async

▶ PYTHONeditable · runs in your browser

requests is synchronous. Even inside an async def, requests.get blocks the entire loop until the response arrives. Use httpx (or aiohttp) for async HTTP.

Rule: never use a synchronous I/O library inside async code. If the library doesn't have an async variant, wrap it in run_in_executor.

asyncio.TaskGroup — the 2026 way to fan out

Python 3.11 added TaskGroup, which fixes the fire-and-forget problem cleanly:

▶ PYTHONeditable · runs in your browser

Structured concurrency — no lost errors, no orphaned tasks. Prefer this over bare create_task in modern code.

Real-world example: rate-limited scraper

▶ PYTHONeditable · runs in your browser

All four patterns in 15 lines: bounded concurrency (semaphore), timeouts (wait_for), explicit error branches (no swallowing), and gather for fan-out. Copy-paste this shape into any async I/O task.


Async in Python has a real learning curve, but the mental model is simple: one thread, cooperative pauses at await, I/O overlaps. Master gather + Semaphore + wait_for + TaskGroup and you'll have covered 95% of async work you'll ever write.

Next step: the Senior Python track has 20 hands-on lessons on asyncio, TaskGroup, aiohttp/httpx, and the concurrency patterns above — with real Python running in your browser.

Ready to try it?

15 lessons free after a free signup — no card. Sample the platform, then decide.

Start free

Or unlock every lesson

Pro — $12/mo, unlimited AI, cancel any time. Refund within 14 days worldwide.

See Pro plans

Get one Python lesson + one career idea every Friday

No spam, no "buy our course now". Three bullets, every Friday. Unsubscribe with one click.