Python Coroutine Lifecycle — Core Concepts

The States of a Coroutine

A coroutine object in Python goes through distinct states. Understanding these states explains why await exists and what happens when you forget to use it.

1. Created (Not Started)

Calling an async def function doesn’t execute it — it returns a coroutine object:

async def fetch():
    return 42

coro = fetch()  # Nothing runs. coro is a coroutine object.

At this point, no code inside fetch has executed. The coroutine is inert.

2. Running

The coroutine starts running when something sends a value into it. Typically, the event loop does this via Task.__step(), which calls coro.send(None). The code inside the function executes normally until it hits an await.

3. Suspended

When the coroutine reaches an await, it yields control back to the event loop. The coroutine’s local variables, instruction pointer, and call stack are preserved in the coroutine frame — nothing is lost.

async def fetch():
    data = await download("https://example.com")  # Suspends here
    return process(data)  # Resumes here when download completes

4. Completed

The coroutine either returns a value (raising StopIteration internally) or raises an exception. Once completed, it cannot be restarted.

How the Event Loop Drives Coroutines

The event loop doesn’t know about async/await directly. It works with callbacks. The Task class bridges the gap:

  1. asyncio.create_task(coro) wraps the coroutine in a Task
  2. The Task schedules itself with call_soon(self.__step)
  3. __step calls coro.send(None) — the coroutine runs until it suspends
  4. The suspension point yields a Future — the Task attaches a wakeup callback
  5. When the Future resolves, the wakeup callback re-schedules __step

This send-suspend-resume cycle repeats until the coroutine completes.

The “Never Awaited” Warning

If you create a coroutine but never schedule it, Python warns you:

RuntimeWarning: coroutine 'fetch' was never awaited

This happens because the coroutine object was garbage collected without ever being driven. It’s a common bug — the fix is either await fetch() or asyncio.create_task(fetch()).

Coroutine vs. Task vs. Future

These three are related but distinct:

  • Coroutine — a suspended function. Has no result until driven.
  • Task — a coroutine wrapped in a scheduler. It drives the coroutine and stores its result.
  • Future — a placeholder for a result that doesn’t exist yet. Tasks inherit from Future.

A coroutine becomes useful when wrapped in a Task. The Task manages the lifecycle; the coroutine is just the code.

Common Misconception: “await Runs the Coroutine”

Not quite. await suspends the current coroutine until the awaited thing resolves. The event loop decides what runs next. You can await a coroutine (which implicitly wraps it in a Task), a Future, or any object with an __await__ method.

Inspecting Coroutine State

The inspect module lets you check where a coroutine is in its lifecycle:

import inspect

coro = fetch()
print(inspect.getcoroutinestate(coro))  # CORO_CREATED
# After first send: CORO_SUSPENDED
# After completion: CORO_CLOSED

The four states are: CORO_CREATED, CORO_RUNNING, CORO_SUSPENDED, and CORO_CLOSED.

One thing to remember: A coroutine is a state machine with four states — created, running, suspended, closed — and the event loop’s job is to push it through those states by repeatedly calling .send().

pythonconcurrencyasynciocoroutines

See Also

  • Python Actor Model Why treating each piece of your program like a person with their own mailbox makes concurrency way less scary.
  • Python Aiocache Caching aiocache remembers expensive answers so your async Python app doesn't waste time asking the same question twice.
  • Python Aiofiles Async Io aiofiles lets your async Python program read and write files without freezing — because normal file operations secretly block everything.
  • Python Aiohttp Understand Aiohttp through an everyday analogy so Python behavior feels intuitive, not random.
  • Python Anyio Portability AnyIO lets your async Python code work with any async library — write once, run on asyncio or Trio without changes.