Skip to content
intermediate

Python asyncio Basics: Use async and await for I/O

Your script fetches three URLs. Each one takes a second. The whole thing takes three seconds, and for almost all of that time your CPU is doing nothing but…

Published 2026-10-02Updated 2026-10-049 min read
Close-up of a computer screen displaying colorful programming code with depth of field.
Close-up of a computer screen displaying colorful programming code with depth of field. Photo by Godfrey Atima on Pexels.

Your script fetches three URLs. Each one takes a second. The whole thing takes three seconds, and for almost all of that time your CPU is doing nothing but waiting.

That is the problem asyncio exists to solve. Not speed in the sense of more computation per second — reclaimed waiting. When your program is blocked on a network call, a disk read, or a subprocess, asyncio lets it go do something else instead of sitting there. This article shows you the mechanism with one small script you can run twice and time yourself.

Why Waiting Is the Real Cost

Before any syntax, get the diagnosis right. Work falls into two rough categories:

  • I/O-bound work spends most of its time waiting on something outside the CPU: a network response, a file read, a database query, a subprocess.
  • CPU-bound work keeps a core busy from start to finish: crunching numbers, parsing a huge file, resizing images.

Asyncio is built for the first category. If you have three tasks that each wait one second on a network call, running them one after another costs about three seconds of mostly nothing. The CPU is not slow. It is unemployed, and your program has no way to hand it other work.

That is the entire pitch. And the boundary matters just as much: asyncio does not make CPU-heavy loops faster, and it does not create parallel cores. If your bottleneck is arithmetic, asyncio buys you nothing. Keep that rule in your head, because it explains most of the confusion beginners run into later.

Knowledge check

Check your understanding

Answer this question before you continue.

A program makes three network requests, each of which spends about one second waiting. Why might asyncio help?
Single Choice

Focus: Identify when asyncio can reduce elapsed time by overlapping I/O waits.

Your First async and await Example

Let's start with the smallest thing that shows the mechanism. Save this as first_async.py:

import asyncio

async def say_after(delay, message):
    await asyncio.sleep(delay)
    print(message)

async def main():
    print("start")
    await say_after(1, "one second later")
    print("end")

asyncio.run(main())

Run it:

python first_async.py

Expected output:

start
one second later
end

Three things are happening here, and each one matters.

async def defines a coroutine function. Calling it does not run the body — it returns a coroutine object, a suspended piece of work that has not started yet. That is the first surprise most people hit: if you write say_after(1, "hi") without await, nothing prints. Python may even warn you that the coroutine was never awaited.

await is what actually runs that coroutine object and pauses the current flow until it finishes. When you await say_after(...), the body executes, hits await asyncio.sleep(delay), and hands control back to the event loop. This is the line that makes everything else possible. Compare it to time.sleep(delay), which blocks the entire program — no other task can run while it waits. await asyncio.sleep() yields; time.sleep() holds the whole thread hostage.

asyncio.run(main()) is the entry point. It creates an event loop, runs your coroutine on it, and closes the loop when the coroutine finishes. You call it once, at the top level of your program.

Common mistake: Using time.sleep() inside a coroutine. It looks harmless, but it blocks the event loop and silently kills all concurrency. If you are inside an async def, reach for await asyncio.sleep().

Knowledge check

Check your understanding

Answer this question before you continue.

What happens when this code runs?
Output Prediction

Focus: Predict what happens when an async function is called without being awaited or scheduled.

async def greet():
    print("hello")

greet()

Sequential vs Concurrent: Run It Twice

Two timelines compare three one-second tasks: sequential tasks A, B, and C run one after another across three seconds; gathered tasks A, B, and C overlap across the same one-second interval.
With gather(), the waits overlap instead of adding up.

One coroutine waiting is not interesting. The payoff shows up when you have several. Let's write the same three tasks two ways and time them.

Version A — sequential, one await after another:

import asyncio
import time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    print(f"finished {name}")

async def main():
    start = time.perf_counter()
    await fetch("A", 1)
    await fetch("B", 1)
    await fetch("C", 1)
    print(f"total: {time.perf_counter() - start:.2f}s")

asyncio.run(main())

Expected output:

finished A
finished B
finished C
total: 3.00s

Version B — the same coroutines wrapped in asyncio.gather():

import asyncio
import time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    print(f"finished {name}")

async def main():
    start = time.perf_counter()
    await asyncio.gather(
        fetch("A", 1),
        fetch("B", 1),
        fetch("C", 1),
    )
    print(f"total: {time.perf_counter() - start:.2f}s")

asyncio.run(main())

Expected output:

finished A
finished B
finished C
total: 1.00s

Same work. Same one thread. Same one core. Three seconds became one, because all three waits overlapped instead of stacking.

The difference between the two versions is worth naming precisely. In Version A, each await fetch(...) runs one coroutine to completion before the next line executes. In Version B, asyncio.gather() receives three coroutine objects, schedules them as tasks on the event loop, and lets them make progress concurrently. await alone does not create concurrency — it just waits for one thing. gather() is what arranges multiple awaitables to run side by side.

Notice that the printed order may not match the order you called them in. That is not a bug — it is the whole point. The event loop is a scheduler, and it switches between tasks only at await points. This is cooperative concurrency: tasks give up control voluntarily. Nothing preempts them. If a task never awaits, it runs to completion and starves everything else.

That is also why the mental model is "concurrency without parallelism." One core, one thread, many overlapping waits.

Note: The asyncio.sleep() calls in these examples simulate I/O waiting. They stand in for a real network request, disk read, or database query. The timing behavior is the same, but the actual I/O is not happening. To make this real, you would replace the sleep with an await on an operation from an async-capable client — for example, an async HTTP library or an async database driver. A plain synchronous library cannot simply be awaited.

Knowledge check

Check your understanding

Answer this question before you continue.

Three coroutines each await `asyncio.sleep(1)`. If they are passed to `asyncio.gather()` and awaited together, about how long will the waits take overall?
Output Prediction

Focus: Estimate the elapsed time for several equal-duration waits run with asyncio.gather().

What the Event Loop Is Actually Doing

Here is the model I want you to keep, because it lets you predict behavior instead of memorizing syntax.

The event loop maintains two sets: tasks that are ready to run, and tasks parked on pending I/O. When a coroutine hits await, it registers what it is waiting for and returns control to the loop. The loop picks the next runnable task. When the awaited operation completes, the parked task is rescheduled and resumes right where it left off.

That "resumes where it left off" is the part a regular function cannot do. A normal function runs to completion and returns. A coroutine can be suspended and resumed, which is what makes overlapping waits possible in a single thread.

The critical consequence: a coroutine that never awaits blocks the loop entirely. The loop cannot interrupt running code. It can only choose what to run next when the current task yields. This is why one stray time.sleep() or a tight CPU loop inside a coroutine freezes every other task in your program.

Knowledge check

Check your understanding

Answer this question before you continue.

A coroutine enters a long CPU loop and never reaches an `await`. Why can another task on the same event loop remain stuck waiting?
Misconception Check

Focus: Explain why a coroutine that never yields can prevent other tasks from making progress.

When to Use asyncio and When to Skip It

Reach for asyncio when many tasks spend most of their time waiting on network, sockets, or async-capable libraries. Skip it when the work is CPU-bound, or when your libraries are synchronous with no async equivalent — await only works on awaitable objects, and a plain synchronous database driver cannot simply be awaited.

ApproachGood forAvoid whenCost
SequentialSimple scripts, few tasks, easy debuggingMany slow I/O callsTotal time is the sum of every wait
ThreadingBlocking I/O with synchronous librariesShared mutable state gets trickyRace conditions, GIL limits CPU work
MultiprocessingCPU-bound work across coresTasks are mostly waitingProcess startup, memory, IPC overhead
asyncioMany concurrent I/O waits in one threadCPU-bound work, sync-only librariesRequires async-aware libraries and discipline

The honest limitation: async support depends on the library. If you want async database queries, you need a driver that supports async and await. This is also why asyncio shows up everywhere in real codebases — it is the foundation for async web frameworks and network clients you will meet as you go.

Common Mistakes That Break the Illusion

These are the failures that make beginners conclude asyncio is broken or useless. It usually is not.

  • time.sleep() inside a coroutine. Blocks the loop, kills concurrency. Use await asyncio.sleep().
  • Forgetting await. The coroutine object is created and never runs. Python may warn you about it.
  • Confusing a coroutine object with a scheduled task. Calling an async function creates a coroutine object. It does not start running until you await it or hand it to something like asyncio.gather() or asyncio.create_task().
  • Calling asyncio.run() twice in one program, or nesting it inside a running loop. It is a top-level entry point, not a reusable helper.
  • Assuming output order equals call order. Concurrent tasks interleave.
  • Expecting a speedup on CPU-bound work. You will not get one, and that is by design.
  • Treating every await as a safe checkpoint. Shared state can still be read and written across a suspension point. Cooperative concurrency reduces data races; it does not eliminate them.

Practice: Make the Wait Visible

Write a script with three coroutines that each await a different sleep duration — say 1, 2, and 3 seconds. Run them sequentially, then with asyncio.gather(). The sequential total should be about 6 seconds; the gathered total should be close to 3.

Print a timestamp at the start and end of each coroutine so you can watch the interleaving. Then extend it: replace one sleep with a real network call using an async-capable client, and notice that the pattern is identical.

For now, memorize four things: async def, await, asyncio.run(), and asyncio.gather(). Look up tasks, queues, locks, and timeouts when you actually need them.

The next practical step is to take an existing script that loops over slow calls — API requests, file downloads, anything that waits — and convert that loop into a gather() call. That single change is the same pattern behind the async web frameworks and network clients you will run into later.

Knowledge check

Final check

Finish the article by checking the ideas you just learned.

Which situation is the best fit for asyncio, as described in the article?
Question 1 of 2Single Choice

Focus: Choose asyncio for a workload with many waits and async-capable I/O libraries.

In the practice exercise, three coroutines await sleeps of 1, 2, and 3 seconds. When run with `asyncio.gather()`, about how long should the total take?
Question 2 of 2Output Prediction

Focus: Predict how concurrent waits of different durations determine the total gathered runtime.

References

  1. asyncio — Asynchronous I/O — Python 3.14.8 documentationdocs.python.org
  2. Python Asyncio Part 1 – Basic Concepts and Patterns | cloudfit-public-docsbbc.github.io
Practical resource

Want a more structured Python path?

Use the Python Starter Pack to turn scattered tutorials into a focused practice path.

View the bundle
Coming soon

Python for Artificial Intelligence Starter Pack

Build a Python foundation you can actually use. The Python for AI Starter Pack brings together a guided path through setup, core programming concepts, data structures, files, JSON, APIs, debugging, and practical projects—so you can move quickly from running your first program to understanding and building useful software.

$9
PDF BundlePythonAIBeginner
  • 264-page illustrated PDF
  • 12 guided Python chapters
  • Visual concept diagrams
  • Self-assessment quizzes
  • Bonus deep-dive sections
  • Files, JSON, APIs, debugging & projects
  • Foundation for data, automation & AI

Coming soon

Free Python bundle

Get the LearnPyFast Python for Artificial Intelligence Starter Bundle

Build a Python foundation you can actually use. The Python for Artificial Intelligence Starter Pack brings together a guided path through setup, core programming concepts, data structures, files, JSON, APIs, debugging, and practical projects—so you can move quickly from running your first program to understanding and building useful software.

You’ll receive the bundle by email. You can unsubscribe anytime.

No spam. You can unsubscribe anytime. See our Privacy policy.

Related sites

Continue beyond Python

Explore related Worldmonger sites when you want to move from Python basics into JavaScript or LLM application building.

JavaScript tutorialstutorial

LearnJSFast

Beginner-friendly JavaScript tutorials for practical web development and self-taught developers.

JavaScriptFrontendWeb development
Visit LearnJSFast
LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast

Keep learning

Related tutorials

Continue with nearby Python topics and beginner-friendly explanations.

Captivating view of a stormy sea under dark clouds, showcasing powerful ocean waves.
beginner
6 min read

Beginner Python Project Ideas

You finished the syntax tutorials. You know what a loop does, you can write a function, and you understand what a dictionary is for. Then you close the…

Read tutorial