Python Coroutines Tutorial: How Async Code Works

A Python coroutines tutorial explains how to write functions that can pause while waiting and resume later, allowing a program to make progress on other work in the meantime. In modern Python, coroutines are usually written with async def and used with await, often as part of an asynchronous event loop.

What a coroutine is

A coroutine is a kind of function whose execution can be suspended and resumed. Unlike an ordinary function, which normally runs from its first statement to its return, a coroutine can reach an await expression, yield control, and continue after the awaited operation is ready.

This is useful for input and output that may take time: receiving data from a network, reading from an asynchronous stream, or waiting for a response from another service. While one task is waiting, the event loop can run another task. Coroutines do not make a slow operation itself faster; they help avoid leaving the program idle while that operation is pending.

In current Python, define a coroutine function with async def. Calling it creates a coroutine object, but does not run its body to completion:

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

pending = greet()  # A coroutine object; the body has not run yet

To execute it, await it from another coroutine or pass it to an appropriate event-loop entry point. This distinction between defining, calling, and running a coroutine is central to understanding asynchronous Python.

How async and await work

The async keyword marks a coroutine function. The await keyword says that the current coroutine needs the result of an awaitable object before it can continue. While that object is waiting, control can return to the event loop, which may schedule other ready work.

Here is a small example using a delay to demonstrate cooperation:

import asyncio

async def task(label):
    print(f"Starting {label}")
    await asyncio.sleep(1)
    print(f"Finished {label}")

async def main():
    await asyncio.gather(task("A"), task("B"))

asyncio.run(main())

The sleep is asynchronous: it gives the event loop an opportunity to run other work rather than blocking the thread for the whole wait. The tasks can therefore overlap their waiting periods. The printed order of their completion should not be treated as a general guarantee when tasks perform independent asynchronous work.

await is not a general-purpose way to pause an ordinary function. It is used inside an asynchronous function, and the awaited value must support the awaitable protocol. Coroutines, tasks, and futures are common awaitables, though they have different roles.

Coroutines, tasks, and futures

These terms are related but not interchangeable. A coroutine object represents the suspended computation created by calling an async def function. A task is an event-loop-managed wrapper that schedules a coroutine to run. A future represents a result that may become available later and can be awaited.

  • Coroutine: the asynchronous computation itself, which can be awaited.
  • Task: scheduled work that can progress alongside other tasks in the same event loop.
  • Future: a low-level placeholder for a result that will be supplied later.

Awaiting a coroutine directly is often the clearest choice when the next step depends on its result. Create a task when independent work should start running while the current coroutine continues. For example, a program might start a background request, prepare other data, and then await the request’s task when its result is needed.

Tasks need appropriate lifecycle management. If a task is created and then forgotten, errors may be overlooked or work may be cancelled when the surrounding program ends. Keep a reference to important tasks, await them, and decide how failures and cancellation should be handled.

Running a coroutine and choosing a pattern

A coroutine needs an event loop to make progress. For a simple command-line program, asyncio.run(main()) is the usual starting point: it manages the event loop for the top-level coroutine and closes it when that call finishes. It is generally not appropriate to call asyncio.run from code that is already running inside an event loop.

Within asynchronous code, use await to run work in sequence when each step depends on the previous result. Use task scheduling or a concurrency helper when operations are independent and should overlap. Choose based on the relationship between the operations, not just because asynchronous syntax is available.

  1. Identify the operation that may wait, such as asynchronous network or stream input.
  2. Check that the library provides an asynchronous interface for that operation.
  3. Place the operation in a coroutine and await its result.
  4. Use a task or concurrency helper only when independent work should proceed concurrently.
  5. Ensure that tasks finish or are deliberately cancelled before the program exits.

For multiple operations, a structured concurrency tool such as asyncio.TaskGroup can help keep related tasks within a clear lifetime: the group waits for its tasks when its scope ends and reports failures according to its rules. Check the Python version and official documentation for the exact features available to your project.

When coroutines help—and when they do not

Coroutines are especially useful when one thread needs to manage many operations that spend much of their time waiting on supported asynchronous input and output. A server handling many open connections is a common example. They can also help a client program coordinate several independent requests without dedicating a separate thread to each wait.

They are not automatically the right tool for every slow program. CPU-heavy calculations do not yield merely because they are placed in an async def function. A long calculation that contains no awaitable operation can prevent the event loop from switching to other tasks. Such work may need a process, a thread, or a different design, depending on the workload and library support.

Ordinary synchronous code remains a sensible choice for short scripts and tasks that do not benefit from overlapping waits. Mixing synchronous blocking calls into an asynchronous program can undermine responsiveness. When a required library has only a blocking interface, consult its documentation and consider an appropriate thread-based integration rather than assuming await will make it non-blocking.

Common errors and practical debugging

A frequent mistake is calling a coroutine function and never awaiting or scheduling the coroutine it returns. Python may warn that the coroutine was never awaited, and the intended work will not have run. The fix is to await it from asynchronous code or schedule it as a task when concurrent execution is intended.

Another common error is attempting to use await at the top level of a regular Python script. Put the asynchronous work inside a coroutine and use an event-loop entry point such as asyncio.run. Interactive environments may already manage an event loop, so their rules can differ; use the environment’s guidance rather than starting another loop blindly.

Watch for blocking calls inside coroutines. A synchronous sleep or blocking file or network operation can stop the event-loop thread, delaying every task that depends on it. Use a genuinely asynchronous API where available, or move blocking work out of the event-loop thread using a suitable, documented approach.

Also handle exceptions and cancellation deliberately. An awaited coroutine can raise an exception just like a regular function. If several tasks are running, decide whether one failure should stop related work, whether results should be collected independently, and how cancellation should be allowed to clean up resources. Clear task ownership makes these decisions easier to review.

How coroutines compare with threads

Coroutines and threads can both support overlapping work, but they coordinate differently. In typical asyncio code, tasks share an event-loop thread and switch at await points. Threads are managed by the operating system and may run blocking functions without requiring those functions to be rewritten as coroutines.

Cooperative switching makes asynchronous control flow explicit: a coroutine normally gives other tasks a chance to run when it awaits. That can make large input-and-output workloads efficient, but a blocking call or long section without awaits can stall the loop. Threads can be convenient for adapting blocking libraries, though shared state and coordination require care.

Neither approach is a universal replacement for the other. The key questions are whether the work is mostly waiting or computing, whether the libraries support asynchronous calls, and how the program should coordinate errors and shutdown.

Frequently Asked Questions

Does calling an async function run it?
No. Calling an async def function creates a coroutine object. It must be awaited or scheduled to execute.

Is every coroutine concurrent?
No. Awaiting one coroutine directly can be sequential. Concurrency requires scheduling independent work so the event loop can make progress on multiple tasks.

Can I use await in a normal function?
Not directly. Put the operation in an asynchronous function, then await that function from another coroutine or use a suitable event-loop entry point.

Do coroutines make CPU-heavy code faster?
Usually not. Coroutines are designed to cooperate around waits. A CPU-heavy section that does not yield can block other tasks on the same event loop.

Should I use asyncio.run every time?
No. It is a useful top-level entry point for a regular program. Inside an already-running event loop, await the coroutine through that environment’s supported mechanism instead.

Putting the concepts together

A practical way to approach asynchronous Python is to start with the program’s waiting points. Define coroutine functions for operations with asynchronous interfaces, await dependent steps in order, and schedule independent tasks only when overlapping them is useful. Keep tasks within a clear lifecycle, avoid blocking the event loop, and use threads or processes when the workload or library calls for them.

Once the difference between a coroutine object and scheduled work is clear, the syntax becomes easier to reason about. The central idea is simple: await lets a coroutine pause at an operation that can wait, giving the event loop a chance to run other ready work before the coroutine resumes.