Start Concurrent I/O with asyncio
Write a coroutine and use asyncio.gather to collect independent simulated I/O results in input order.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Start Concurrent I/O with asyncio
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
A program can spend a lot of time waiting for a network response, a database query, or a timer. While one independent operation waits, another may be able to make progress. Python's asyncio organizes that kind of concurrency with coroutines and an event loop. We start with an async def function, but calling it does not immediately run its body to completion. The call creates a coroutine object. To get a result, we await it inside async code, or drive it from a top-level script with asyncio.run. That is the first source of confusion we will demonstrate. Next, we will compare waiting for two tasks one after the other with giving both tasks to asyncio.gather.
- 00:43
The latter overlaps their waiting periods and returns results in the same order as the arguments, even if the tasks finish in a different order. This is useful for independent I/O. It does not make ordinary CPU calculations run on two processor cores by magic. Our examples use tiny asyncio.sleep calls as stand-ins for I/O so they work without a real network connection. On PythonLessonLab, the companion lesson provides a browser console, worked examples, a checked coroutine task, typical mistakes, and a quiz. Before we run code, predict what printing a coroutine object would mean: it is a promise of work, not the work's returned string.
- 01:27
The async function call produced a coroutine object. Its body did not reach the print statement. We close that unused coroutine in this demonstration so Python does not emit an unawaited-coroutine warning after the program ends. In a real program, closing it is not the fix for work you meant to perform. The fix is to await the coroutine, or schedule it and await the resulting task. That distinction matters because an accidentally forgotten await can make a program seem to skip an operation entirely. An async function is not the same as a background thread that starts when called. It is a unit of work the event loop must drive.
- 02:08
We will now use asyncio.run at the top of a simple script to drive one coroutine. Inside another async def function, we would use await rather than starting a second event loop. If you call asyncio.run from inside an already running event loop, Python raises RuntimeError. Browser notebooks and web runtimes may already have a loop; this site's recent Pyodide runtime is designed to support the examples, and a local Python script is another reliable place to try them. The precise environment matters when teaching async, so we will keep the code small and verify it in the actual lesson console.
- 02:48
Also notice that await is a suspension point. The coroutine may pause there while other ready tasks take a turn. In the sequential function, we wait for A to finish before creating and awaiting B. The trace shows start A, end A, start B, and end B. In the gathered version, both coroutines are handed to the event loop together. A reaches its suspension point, then B can start before A has ended. The second trace therefore shows both starts before both ends. The result list remains A then B in both cases, because gather keeps results in argument order. That result order is independent of completion order and is useful for independent requests whose responses must stay attached to their original inputs.
- 03:34
A real I/O version might be faster with gathering because waiting periods overlap, but the exact improvement depends on the service and the runtime. We use an event trace here instead of a brittle stopwatch assertion. There are other asyncio tools for cancellation, timeouts, and task groups, but those are later topics. For now, ask whether the operations are truly independent and whether they spend time awaiting something. If B needs A's result, they cannot be started blindly together. If each job performs CPU-heavy arithmetic without an await point, wrapping them in async functions is not a performance plan.
- 04:15
We are deliberately using sleep as a safe stand-in for I/O, not claiming that real network responses always arrive on schedule. Calling fetch_label creates one coroutine for one item. It yields once with sleep zero, then returns an uppercase numbered label. collect_labels enumerates the source starting at one, creates a coroutine for each pair, and passes them to gather. We await the gathered result and convert it to a list for a straightforward function contract. There is no HTTP request here. The goal is to see how calls, awaits, and collected results relate without worrying about network credentials or a flaky external service.
- 04:57
The output order matches the input positions. If the labels were b, a, b, we would get three separate results numbered one, two, three, including both occurrences of b. An empty input yields an empty result. In an actual web scraper or API client, each worker might await an HTTP request. You would also need rate limits, timeouts, error handling, and permission to access the target service. Those concerns are important, but they are outside this first lesson on asyncio syntax and scheduling. The practical task asks you to fill in the return value for fetch_label and the gather expression for collect_labels.
- 05:39
Run the checker after both functions are complete. If you see a coroutine object instead of a list, look for an await that was omitted or a top-level call that was not driven by the event loop. The direct worker check proves fetch_label is a coroutine function and returns the requested transformation when driven. The three-item check covers input order and duplicates. The empty-list check confirms that the caller receives a predictable list rather than None. These are behavior checks, not a stopwatch benchmark. The event trace earlier was there to explain overlapping waits without making a fragile timing assertion.
- 06:19
Open the matching PythonLessonLab lesson, type the missing return statement and gather expression, then run the practical checker. The quiz asks you to distinguish a coroutine object from its result, recognize where await belongs, and explain gather's result order. If a browser environment reports that an event loop is already running when you use asyncio.run, do not nest another run call; use await from within that environment's async context. In a regular Python script, asyncio.run is the normal top-level entry point. When designing your own async workflow, start with correctness: identify which operations are independent, return results in a useful shape, and decide how errors should reach the caller.
- 07:06
Then measure whether concurrency helps under real I/O. Asyncio is a way to organize cooperative waiting, not a shortcut around understanding the work. Once that distinction is clear, more advanced topics such as timeouts, cancellation, and task groups will make much more sense.
Understand the concept
An async def function creates a coroutine when called; its body does not run to completion merely because the call was made. The caller must await it or schedule it in an event loop. await marks a point where the coroutine can pause while waiting for another awaitable. This is useful for I/O tasks that spend time waiting, such as network requests. It does not make CPU-heavy Python calculations run in parallel automatically.
asyncio.sleep is a clear stand-in for an I/O wait: it suspends the current task and lets other ready tasks run. Awaiting three sleeps one after another makes their delays add up. Passing the coroutines to asyncio.gather starts them together and returns results in the same order as the input awaitables, even if completion order differs. Do not infer that gather makes every operation faster; it helps when tasks have independent waiting periods.
Ask whether the jobs depend on each other before choosing a pattern. If the second job needs the first job's result, await them in sequence and pass that result on. If both jobs can start from the same known inputs and spend time waiting independently, gather can overlap their waits. The worked comparison below counts how many jobs have entered a suspended wait at once, so the difference is visible without making a fragile wall-clock speed claim.
asyncio.run(main()) creates and manages an event loop for a top-level Python script. It must not be called from inside an already running event loop. In environments that already provide a loop, await main() instead. This site's browser runtime supports the examples through its Python worker; normal local scripts can use asyncio.run. Avoid creating a coroutine and forgetting to await it, because the work may never happen and Python can warn about an unawaited coroutine.
The checked exercise defines fetch_label(index, label), which yields once with asyncio.sleep(0), then returns a numbered uppercase label. collect_labels(labels) gathers one coroutine per label and returns the resulting list. The prepared call preserves the input order; tests include an empty list, duplicates, and a direct coroutine check. Real HTTP requests need their own async client and timeout handling; this lesson concentrates on scheduling and result collection without network dependencies.
- Calling async def produces a coroutine
- await lets a task suspend
- Sequential await is right when a later job needs an earlier result
- gather overlaps independent waits and preserves result order
- asyncio.run belongs at a script's top level
See it step by step
Read the code, predict the output, then compare it with the result.
01. A coroutine needs an event loop
import asyncio
async def greeting():
await asyncio.sleep(0)
return "Hello, Python"
print(asyncio.run(greeting()))Hello, PythonCalling greeting makes a coroutine; asyncio.run drives it and prints the returned text.
02. Gather independent waits in input order
import asyncio
async def label(number, delay):
await asyncio.sleep(delay)
return f"item {number}"
async def main():
return await asyncio.gather(label(1, 0.02), label(2, 0))
print(asyncio.run(main()))['item 1', 'item 2']The second task may finish first, but gather's result list follows argument order.
03. See sequential waits versus overlapping waits
import asyncio
async def main():
state = {"active": 0, "peak": 0}
async def job(name):
state["active"] += 1
state["peak"] = max(state["peak"], state["active"])
await asyncio.sleep(0)
state["active"] -= 1
return name
first = await job("A")
second = await job("B")
print("sequential:", [first, second], state["peak"])
state["peak"] = 0
together = await asyncio.gather(job("A"), job("B"))
print("gather:", together, state["peak"])
asyncio.run(main())sequential: ['A', 'B'] 1
gather: ['A', 'B'] 2Sequential awaiting has at most one job inside its wait. gather starts both independent jobs, so two can be suspended together; the returned values still follow argument order.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Separate a coroutine object from its result
The async keyword changes what a function call produces. Calling the function creates a coroutine object; it does not immediately deliver the return value. A top-level script can pass that coroutine to asyncio.run, which starts an event loop and waits for the result. This site uses a recent Pyodide build in a browser worker; the same simple script syntax works there in supported browsers. Inside an already running event loop, use await rather than calling asyncio.run again.
The example does not print the coroutine object because its representation contains an address that varies by run. It uses the `inspect.iscoroutine` predicate, then executes the coroutine and prints the stable result. Notice that `await asyncio.sleep(0)` yields control without adding a noticeable real delay. This makes the scheduling idea visible without requiring a network service.
import asyncio
import inspect
async def greeting():
await asyncio.sleep(0)
return "Hello"
pending = greeting()
print(inspect.iscoroutine(pending))
print(asyncio.run(pending))True
Hello- greeting() creates a coroutine rather than returning Hello immediately.
- iscoroutine confirms the object type without printing an unstable address.
- asyncio.run drives the coroutine to its returned value.
Overlap independent waits with gather
Suppose two independent jobs spend time waiting for I/O. Awaiting the first one and then the second makes the waits happen sequentially. asyncio.gather accepts both awaitables and lets the event loop make progress on whichever is ready. It returns a list-like result in the same order as its arguments. That order is independent of which job finishes first. This matters when each result must stay attached to its original input.
The example gives the first job a slightly longer simulated wait. The output still puts first in position one. Do not read this as proof of a universal speedup: these are simulated waits, and gather is useful when operations can proceed independently. CPU-heavy calculations without await points will not become parallel merely by wrapping them in async def.
import asyncio
async def finish(label, delay):
await asyncio.sleep(delay)
return label
async def main():
return await asyncio.gather(finish("first", 0.02), finish("second", 0))
print(asyncio.run(main()))['first', 'second']- Both coroutines are passed to gather together.
- The second simulated wait can finish first.
- The results still follow the argument order first, second.
Collect numbered labels with no network dependency
The practice task uses the same scheduling pattern with a deterministic transformation. Each fetch_label coroutine yields once and then uppercases one string. collect_labels creates those coroutines from the input sequence, numbering them from one, and awaits gather. Nothing contacts the network, so the output can be checked immediately in the browser. Real network code needs an async HTTP client, timeouts, and error policy; those concerns would obscure the event-loop basics here.
Gather preserves input position even if tasks finish in a different order. An empty input produces an empty list rather than a special None value. Duplicated labels remain separate numbered results. Run the code and then change the list to ['b', 'a', 'b'] to predict the three outputs. The checked task tests that same property plus a direct call to the worker coroutine.
import asyncio
async def fetch_label(index, label):
await asyncio.sleep(0)
return f"{index}: {label.upper()}"
async def collect_labels(labels):
results = await asyncio.gather(
*(fetch_label(index, label) for index, label in enumerate(labels, 1))
)
return list(results)
print(asyncio.run(collect_labels(["ada", "grace"])))
print(asyncio.run(collect_labels([])))['1: ADA', '2: GRACE']
[]- enumerate supplies stable one-based positions.
- Each call creates a coroutine and gather awaits all of them.
- The result is converted to a list, including the empty case.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Use `return f'{index}: {label.upper()}'` in fetch_label. In collect_labels, `results = await asyncio.gather(*(fetch_label(index, label) for index, label in enumerate(labels, 1)))` and `return list(results)`.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Calling a coroutine without driving it
result = fetch_label(1, 'ada')
print(result)result = asyncio.run(fetch_label(1, 'ada'))
print(result)What happens: The result is a coroutine object rather than '1: ADA', and it may be reported as never awaited.
At a top-level script, asyncio.run executes the coroutine. Inside async code, use await instead.
Awaiting independent waits one at a time
results = []
for item in jobs:
results.append(await fetch(item))results = await asyncio.gather(*(fetch(item) for item in jobs))What happens: Independent waits are serialized, so their delays add up.
gather schedules independent awaitables together; result order remains aligned with jobs.
Starting another event loop inside async code
async def main():
return asyncio.run(fetch_label(1, 'ada'))async def main():
return await fetch_label(1, 'ada')What happens: asyncio.run raises RuntimeError when an event loop is already running.
Use await inside a coroutine and reserve asyncio.run for a synchronous top-level script.
Gathering jobs that depend on each other
await asyncio.gather(fetch_token(), fetch_profile(token))token = await fetch_token()
profile = await fetch_profile(token)What happens: The profile job cannot receive a token that has not been returned yet.
Start independent waits together; keep a real producer-consumer dependency in sequence.