
async def is often discussed as though it were a different language with different
performance rules. It is a keyword that makes a function return a coroutine object, and the
costs are small, specific and measurable — except for one, which is enormous and easy to
write by accident.
Four shapes of the same function
The harness times the same trivial addition four ways: called directly, awaited as a
coroutine, wrapped in a Task and awaited one at a time, and gathered a thousand at a time. A
bare await asyncio.sleep(0) is included as the floor for one loop iteration.
$ python3 experiments/asynccost/calls.py
python 3.13.2 on darwin
200,000 iterations each
shape per op vs plain
plain call 24 ns 1.0x
await coroutine 53 ns 2.1x
await Task 28958 ns 1182.4x
await sleep(0) 14519 ns 592.8x
gather, 1000 wide 1914 ns 78.1x
coroutine object: 192 bytes
The cheap one
Awaiting a coroutine that never suspends costs 53 ns against 24 ns for the plain call. That
29 ns buys the coroutine object, the frame that gets suspended and resumed, and the protocol
by which await drives it. It is a constant, it does not depend on what the coroutine does,
and against any function body that touches a dictionary or formats a string it disappears.
This is the number to have in mind when someone proposes rewriting a helper as async def
so it can be awaited from an async caller. The rewrite is fine. It is not a performance
decision in either direction.
The other constant worth knowing is the object: 192 bytes per coroutine on this build. That is cheap next to a thread, which is the comparison that matters, and expensive next to nothing, which is the comparison that catches people. A hundred thousand coroutines held in a list are 19 MB before a single one has been scheduled.
The expensive one
await asyncio.ensure_future(coro) in a loop measured 28,958 ns per iteration — a thousand
times the plain call, and five hundred times the direct await. That is the spelling the
harness uses; for a coroutine argument asyncio.create_task is the same operation.
Nothing about the coroutine changed. What changed is that the result is no longer produced by a function call; it is produced by the event loop. Creating the Task puts a callback on the ready queue and returns. Awaiting it suspends the caller. The loop finishes the current callback, finds nothing else runnable, polls the selector, wakes, and runs the Task. Then it does the same thing again to deliver the result to the waiting caller.
The await asyncio.sleep(0) row is the same story with the Task removed: 14,519 ns for one
deliberate trip around the loop and back. That is the price of a loop iteration on this
machine, and awaiting a Task individually costs roughly two of them.
The one that matters
The last row is the same coroutines run a thousand at a time under gather: 1,914 ns each.
Same Task machinery, same loop, one round trip amortised across a thousand pieces of work
instead of paid per piece.
This is the actual rule, and it is not about async at all. The event loop charges per trip,
not per coroutine. Work that goes through it in a batch is cheap; work that goes through it
one item at a time pays the full fixed cost every time.
Which is why the shape below is the most expensive way to write the same program:
for row in rows:
await asyncio.create_task(process(row)) # a loop round trip per row
and this one is not:
await asyncio.gather(*(process(row) for row in rows))
The first version creates concurrency and then immediately destroys it by waiting. It reads like the second version. It performs like a synchronous loop with a syscall bolted onto each iteration.
Seeing it in a running program
The loop will tell you when it is being used this way, if it is asked. Debug mode logs any callback that takes longer than a threshold, and the threshold is settable:
loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.005 # default is 0.1 seconds
What that surfaces is the opposite problem — a single callback hogging the loop — but the two
are diagnosed together, because both show up as a loop that is busy while nothing useful is
finishing. A cheap sanity check for the batching mistake is to count trips directly: wrap the
loop’s _run_once during a request and compare the count against the number of items
processed. If they are the same, the work is going through one at a time.
Where the real programs sit
None of these figures describe an application that is actually doing IO. A coroutine awaiting a database round trip spends milliseconds waiting and nanoseconds on the machinery above; the overhead is invisible against the wait, which is the entire point of the design.
They describe the boundary — the code that decides how work reaches the loop. That code is usually a loop over a list, it is usually written once, and it is where the difference between 1,914 ns and 28,958 ns per item is decided.
Where to stop
Everything here is CPython 3.13.2 with the default event loop on macOS. The loop-trip cost in
particular is selector-dependent: a different platform, or uvloop, will produce a different
number for the sleep(0) row and therefore for the Task row.
The ordering is what transfers. Direct await is nearly free, a loop trip is not, and batching
is what turns the second into the first. If a profile shows time inside asyncio.base_events,
the answer is almost never a faster loop — it is fewer trips through the one already there.
Frequently asked
- Is async slower than sync?
- Per call, marginally — 29 ns of extra frame handling. Per unit of concurrency it is dramatically cheaper than a thread. The mistake is using the concurrency machinery for work that is not concurrent.
- Why is awaiting a Task so much more expensive than awaiting a coroutine?
- Awaiting a coroutine directly is a function call into a generator frame. A Task is scheduled: it goes on the loop's ready queue, the loop finishes the current callback, polls the selector and comes back. That poll is a syscall.
- Should I avoid create_task then?
- No. Create tasks when you want things to run concurrently, which is the case that pays for the loop trip. Do not create one for work you are about to await immediately — that is a scheduling round trip for a function call.


