Skip to main content

Your First Coroutine

If you're new to programming

async def creates a special function that can pause and let other tasks run while it waits for I/O. This lesson teaches you the three keywords that make it work: async def, await, and asyncio.run.

If you've coded before

Coroutines, event loop, cooperative multitasking. asyncio.run() as the entry point. This lesson covers the fundamental syntax and the most common beginner mistake (calling an async function without await).

In Lesson 2, threads gave SmartNotes a 27x speedup for I/O. But threads carry shared state risks and require coordination. Emma said there is a cleaner way.

She opens a fresh file and types async def. James watches the screen. "What does the async do?"

"It tells Python this function can pause," Emma says. "Regular functions run from start to finish. Async functions can stop in the middle, let something else run, and resume when they are ready."


async def and await

Create a file called first_coroutine.py:

import asyncio


async def greet(name: str) -> str:
"""Greet someone after a short pause."""
print(f" About to greet {name}...")
await asyncio.sleep(1)
print(f" Hello, {name}!")
return f"Greeted {name}"


async def main() -> None:
result: str = await greet("James")
print(f" Result: {result}")


asyncio.run(main())

Run it:

uv run python first_coroutine.py

Output:

  About to greet James...
Hello, James!
Result: Greeted James

Three new keywords in this code:

KeywordWhat it does
async defCreates a coroutine function. Calling it returns a coroutine object (not the result).
awaitPauses the current coroutine. Says "I am waiting for this operation; run something else if available."
asyncio.run()Creates an event loop, runs the coroutine, and closes the loop when done. This is the entry point.

asyncio.sleep(1) is the async version of time.sleep(1). The critical difference: time.sleep(1) blocks the entire program for one second. asyncio.sleep(1) pauses only the current coroutine, freeing the event loop to run other tasks.

Think of the event loop as a restaurant waiter serving multiple tables. When Table 1 orders and the kitchen starts cooking, the waiter does not stand at Table 1 waiting for the food. They walk to Table 2, take that order, then check if Table 1's food is ready. await is the waiter saying "the kitchen is working on this; let me check other tables." The forklift analogy from Lesson 1 works the same way: the dispatcher routes the driver to whichever dock is ready.

Right now there is only one task (main calling greet), so the behavior looks identical to a regular function. The benefit appears when you have multiple coroutines. That comes in Lesson 4 with asyncio.gather.

If this feels abstract

That is normal. Every programmer finds async confusing the first time. You do not need to fully understand the event loop right now. Focus on two rules: (1) use async def instead of def for functions that wait for I/O, and (2) use await when calling them. The mental model deepens in Lessons 4-6 as you see async in action.


Coroutines Are Not Regular Functions

This is the most common beginner mistake. Create wrong_call.py:

import asyncio


async def greet(name: str) -> str:
print(f" Hello, {name}!")
return f"Greeted {name}"


def main() -> None:
result = greet("James")
print(f" Type: {type(result)}")
print(f" Result: {result}")


main()

Run it:

uv run python wrong_call.py

Output:

  Type: <class 'coroutine'>
Result: <coroutine object greet at 0x...>
sys:1: RuntimeWarning: coroutine 'greet' was never awaited

Three problems:

  1. "Hello, James!" never printed. The function body did not execute.
  2. The result is a coroutine object, not a string. greet("James") returned a coroutine, not "Greeted James".
  3. Python printed a RuntimeWarning. It detected that you created a coroutine but never awaited it.

Calling an async function without await is like putting a task on the to-do list without ever starting it. The coroutine object is the task. await is what tells the event loop "run this now."

Call styleWhat happens
result = greet("James")Returns a coroutine object. Body does NOT execute. Warning.
result = await greet("James")Executes the body. Returns the actual return value.

The fix: await the coroutine inside another async function, and use asyncio.run() to start everything:

import asyncio


async def greet(name: str) -> str:
print(f" Hello, {name}!")
return f"Greeted {name}"


async def main() -> None:
result: str = await greet("James")
print(f" Result: {result}")


asyncio.run(main())

Output:

  Hello, James!
Result: Greeted James

Now the body runs and the return value is a string.


The Event Loop

The event loop is a dispatcher. It keeps a list of tasks and decides which one runs next.

Here is the mental model. Imagine a single forklift driver (the event loop) managing multiple loading docks (coroutines):

  1. The driver picks up a task from the list.
  2. The task runs until it hits an await.
  3. At the await, the task says: "I am waiting for I/O. Come back when it is ready."
  4. The driver picks the next ready task from the list.
  5. When a waiting task's I/O completes, the driver returns to it.

With one coroutine, this loop is trivial: there is nothing else to run while waiting. With multiple coroutines, the driver never sits idle.

asyncio.run(main()) does three things:

  1. Creates an event loop
  2. Runs the main() coroutine until it finishes
  3. Closes the event loop

You call asyncio.run() once, at the top level of your program. Everything else uses await inside async functions.


Async SmartNotes Export

Convert the sequential export from Lesson 1 to async. Create async_export_single.py:

import asyncio
import time


async def export_note(note_id: int, fmt: str) -> str:
"""Simulate exporting a single note to disk."""
await asyncio.sleep(0.1) # Simulated disk I/O
filename: str = f"note_{note_id}.{fmt}"
return filename


async def main() -> None:
start: float = time.perf_counter()

# Export a single note
result: str = await export_note(0, "md")
print(f" Exported: {result}")

elapsed: float = time.perf_counter() - start
print(f" Time: {elapsed:.2f}s")


asyncio.run(main())

Run it:

uv run python async_export_single.py

Output:

  Exported: note_0.md
Time: 0.10s

One note, one format, 0.1 seconds. The same timing as the synchronous version. There is no speedup yet because there is only one task. The event loop has nothing else to switch to during the await.

The speedup comes when you run multiple coroutines at the same time. Lesson 4 introduces asyncio.gather, which launches multiple coroutines and awaits all of them together. The event loop switches between them at each await asyncio.sleep(), and total time drops from the sum of all durations to approximately the duration of the longest one.

For now, the important milestone is this: you wrote an async function, you awaited it, and you ran it with asyncio.run(). The syntax works. The paradigm shift is complete. What remains is applying it to multiple tasks.


PRIMM-AI+ Practice: Predict the Coroutine

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode.

What does each code snippet produce? Write your predictions on paper.

# Snippet A
async def double(x: int) -> int:
return x * 2

result = double(5)
print(type(result))
# Snippet B
import asyncio

async def double(x: int) -> int:
return x * 2

async def main() -> None:
result: int = await double(5)
print(result)

asyncio.run(main())
# Snippet C
import asyncio

async def slow_double(x: int) -> int:
await asyncio.sleep(1)
return x * 2

async def main() -> None:
result: int = await slow_double(5)
second: int = await slow_double(10)
print(f"{result}, {second}")

asyncio.run(main())

For Snippet C, predict both the output and the total execution time.

Check your predictions

Snippet A:

<class 'coroutine'>

Plus a RuntimeWarning. No await, so the body never runs.

Snippet B:

10

Properly awaited inside an async function. Returns the actual integer.

Snippet C:

10, 20

Total time: approximately 2 seconds. Each await slow_double(...) pauses for 1 second, and they run sequentially (one after the other, not concurrently). To run them concurrently, you need asyncio.gather (Lesson 4).

Run

Press Shift+Tab to exit Plan Mode.

Create three files (snippet_a.py, snippet_b.py, snippet_c.py) and run each. For Snippet C, add timing with time.perf_counter() and verify it takes 2 seconds, not 1.

Investigate

Run /investigate @snippet_c.py in Claude Code and ask: "Why does Snippet C take 2 seconds instead of 1? Both calls use await, so why do they not run at the same time? What would I need to change to make them concurrent?"

The answer introduces the distinction between sequential awaits (one after another) and concurrent awaits (asyncio.gather).

Modify

Add a third call to slow_double(15) in Snippet C. Predict the total time (3 seconds). Run and verify. Then add await asyncio.sleep(0) between the calls and observe that it does not change the timing (the sleep(0) yields to the event loop, but there is nothing else to run).

Make [Mastery Gate]

Write an async function async_process(values: list[int], delay: float) -> list[int] that takes a list of integers, sleeps for delay seconds per item (simulating I/O), and returns a list of each value doubled. Use /tdg in Claude Code:

  1. Write the stub with async def, types, and docstring
  2. Write 3+ tests. For now, wrap each test in a regular def test_... that calls asyncio.run(your_async_function()) inside. This works but is verbose; Lesson 6 teaches a cleaner approach with pytest-asyncio
  3. Generate the implementation
  4. Verify with uv run ruff check, uv run pyright, uv run pytest

The total time should equal len(values) * delay because the items process sequentially. This is the baseline you will improve in Lesson 4.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Trace the Event Loop

Here is my async code with two sequential awaits:

import asyncio

async def fetch_data(label: str, delay: float) -> str:
print(f" {label}: starting")
await asyncio.sleep(delay)
print(f" {label}: done")
return f"{label} result"

async def main() -> None:
a = await fetch_data("A", 2.0)
b = await fetch_data("B", 1.0)
print(f" Results: {a}, {b}")

asyncio.run(main())

Trace the event loop step by step. What is the loop
doing during each await? Why does this take 3 seconds
instead of 2? Show me how to visualize the timeline.

What you're learning: Understanding what the event loop does at each await point is the key to async programming. The AI walks you through the timeline, making the invisible dispatcher visible. This prepares you to understand why asyncio.gather reduces total time.

Prompt 2: Convert a Sync Function

Here is a synchronous function that processes items:

def process_all(items: list[str]) -> list[str]:
results = []
for item in items:
time.sleep(0.5) # simulate I/O
results.append(item.upper())
return results

Convert this to an async function. Explain each change
you make and why. What stays the same? What changes?

What you're learning: Converting sync to async follows a pattern: def becomes async def, time.sleep becomes await asyncio.sleep, and the caller must use await and asyncio.run. Recognizing this pattern lets you convert any I/O-bound function.

Prompt 3: Common Async Mistakes

I am new to async Python. What are the 5 most common
mistakes beginners make with async/await? For each,
show the wrong code, the error or symptom, and the fix.
Rank them from most common to least common.

What you're learning: Knowing common pitfalls before you encounter them saves debugging time. The "forgot to await" mistake is number one, but others (mixing sync and async, blocking the event loop with time.sleep, calling asyncio.run inside an async function) are also frequent.


James looks at the async export function. "So the await is the driver saying 'I will be back when this is ready.'"

"And the event loop is the dispatcher who tells the driver which dock to visit next," Emma says. "Right now there is only one dock, so the driver waits at that dock. Lesson 4 adds asyncio.gather, which opens all the docks at once."

"But the single-note export looks the same as the sync version," James says. "Same timing. Same output."

"Correct. One coroutine has nothing to switch to. The benefit is structural: the code is ready for concurrency. You just need to tell the event loop 'run all of these at the same time.' That is one function call."

James looks at the await asyncio.sleep(0.1) line. "And this sleep is not time.sleep. So await is the difference between the forklift parking at a dock and waiting versus the forklift telling the dispatcher 'call me when this dock is ready.'"

Emma blinks. "That is actually a better explanation than mine. I was going to say await yields control to the event loop. Your version is clearer." She writes it down. "The dock analogy captures something the technical description misses: await is not just pausing. It is communicating readiness back to the scheduler."

"So what happens if you accidentally use time.sleep inside an async function?"

"The forklift parks at the dock and ignores the dispatcher. Everything blocks. It is the single most common async mistake. You will see it in code reviews, and now you know what to look for."