A Python coroutine is the awaitable object produced by calling an async def function; it does not run until an event loop awaits or schedules it.
That function-object-task distinction is the hinge for understanding asyncio. Miss it and asynchronous code feels supernatural. Get it right and the event loop becomes a scheduler with very explicit hand-off points.
Table of contents
- Coroutine function, object, and task are different
- Await is a cooperative hand-off
- Create concurrency deliberately
- Treat cancellation as normal control flow
- Choose coroutines for the right workload
- How this fits the rest of the stack
- FAQ
Coroutine function, object, and task are different
async def fetch() defines a coroutine function. Calling fetch() creates a coroutine object. No network request has happened yet. Awaiting that object lets the event loop run it; wrapping it with asyncio.create_task() schedules it concurrently and returns a Task that tracks result, failure, and cancellation.
import asyncio
async def fetch_status():
await asyncio.sleep(0.1)
return 200
async def main():
coroutine = fetch_status()
status = await coroutine
print(status)
asyncio.run(main())
A common warning says a coroutine was never awaited. Python is telling you that you created the work description and then dropped it. Add await, return the coroutine to a caller that will await it, or deliberately schedule a Task.
Await is a cooperative hand-off
An event loop runs one task at a time on its thread. At an await that is not already complete, the current coroutine yields control so another ready task can progress. This is excellent for sockets, database drivers, timers, and other I/O waits. It does not magically make CPU-heavy Python run in parallel.
Use asynchronous libraries end to end. Calling a blocking HTTP client or a slow filesystem function inside a coroutine blocks the loop and every request sharing it. Move unavoidable blocking work to asyncio.to_thread() or a process boundary, and measure before building an elaborate executor maze.
Create concurrency deliberately
Sequential awaits are correct when each result feeds the next operation. Independent I/O can run concurrently with tasks. TaskGroup gives related tasks a structured lifetime: the block waits for them and coordinates failure, which is safer than scattering background tasks across a service.
async def load_page():
async with asyncio.TaskGroup() as group:
user_task = group.create_task(load_user())
plan_task = group.create_task(load_plan())
return user_task.result(), plan_task.result()
Concurrency is not permission to create unlimited work. Bound fan-out with queues, semaphores, connection-pool sizes, and upstream rate limits. A thousand scheduled requests can still be a thousand ways to get throttled at once.
Treat cancellation as normal control flow
Timeouts, client disconnects, deploy shutdowns, and failed sibling tasks can cancel a coroutine. Use try and finally to release resources, and normally re-raise CancelledError after cleanup. Swallowing cancellation can make shutdowns hang and leave tasks doing work nobody wants.
Give service tasks an owner. Keep references to background tasks, expose their failures to logs or metrics, and stop them during application shutdown. Fire-and-forget without ownership is usually fire-and-forget-to-debug-it-at-two-in-the-morning.
Choose coroutines for the right workload
Coroutines shine when one process manages many mostly-waiting operations: API requests, websocket connections, database calls, agents waiting on tools, or webhook delivery. Threads can integrate blocking libraries; processes suit CPU-bound work. The right model follows where time is spent.
In production, watch event-loop lag, request latency, pending task counts, pool saturation, timeout rates, and cancellation behavior. Async code is operational code. It still needs limits, logs, health checks, and a clean deployment lifecycle.
How this fits the rest of the stack
Before choosing a service size for an async API or agent runtime, model concurrency, memory, databases, and outbound traffic in the RunxBuild hosting calculator. When the shape is honest, use the RunxBuild dashboard to deploy it with logs and a live route.
Useful related references:
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python for Websites: Where It Fits and Where It Does Not
- Python services on RunxBuild
FAQ
What is the difference between a coroutine and a task?
A coroutine object describes awaitable work. A Task schedules a coroutine on an event loop and tracks its completion, result, exception, and cancellation.
Does calling an async function run it?
No. Calling an async def function returns a coroutine object. It starts when awaited or scheduled by an event loop.
Are Python coroutines parallel?
They provide cooperative concurrency on an event-loop thread. CPU instructions do not run in parallel merely because a function is async; use threads or processes where appropriate.
When should I use asyncio.run?
Use asyncio.run(main()) as the top-level entry point for a standalone asynchronous program. Frameworks already own an event loop, so application handlers should await work instead of starting another loop.
Why does Python say a coroutine was never awaited?
Code called an async function but neither awaited nor scheduled the returned coroutine. Find that call site and decide who owns the work and its result.