Filter concepts by levelShowing all levels.

Python · Async Python

Async Python

The event loop, coroutines, and the production discipline that keeps one blocking call from freezing everything else

  • async def / awaita coroutine only runs, and only yields, at an explicit await
  • create_task() / gather()run several coroutines concurrently on one thread
  • Semaphore / Queuecap concurrency and apply backpressure deliberately
  • One blocking call ruins ita sync call inside async code freezes the whole loop

1.Event loop and coroutines

The event loop runs one task at a time, switching to another whenever the current one awaits something. A coroutine is a function defined with async def — calling it builds a paused coroutine object, it does not run yet.

Key features

  • async def defines a coroutine function; calling it returns a coroutine object immediately
  • A coroutine object does nothing until awaited — calling the function alone runs no code
  • asyncio.run(main()) creates an event loop, runs main() to completion, then closes the loop
  • Only one coroutine runs Python code at a time — the loop is cooperative, not preemptive
  • A coroutine created but never awaited triggers a "was never awaited" RuntimeWarning

asyncio — the entry points worth knowing

asyncio — the entry points worth knowing
CallEffect
asyncio.run(coro())creates a loop, runs coro() to completion, closes the loop
await coro()runs a coroutine and waits for its result — only valid inside another coroutine
asyncio.create_task(coro())schedules a coroutine to run concurrently, returns a Task immediately
asyncio.get_running_loop()returns the event loop currently running this coroutine
Calling a coroutine builds an object; await is what runs it
main()
greet()
  1. 1coro = greet("Ada")builds a coroutine object — nothing runs yet
  2. 2await coro
  3. 3await asyncio.sleep(0.05)loop switches to other work here
  4. 4return "hello, Ada"
  1. main() → greet(): coro = greet("Ada") (builds a coroutine object — nothing runs yet)
  2. main() → greet(): await coro
  3. greet() → greet(): await asyncio.sleep(0.05) (loop switches to other work here)
  4. greet() → main(): return "hello, Ada"

Command

python
async def fetch(url):
    ...            # a coroutine function

coro = fetch(url)  # builds a coroutine object -- nothing has run yet
result = await coro  # runs it, suspends the caller until it finishes

asyncio.run(main())  # the only place a loop is created from ordinary code

async def call() builds a coroutine object that does nothing; await is what actually runs it and gives back the result.

2.Awaitables, Tasks, and Futures

Anything usable after await is an awaitable — a coroutine, a Task, or a Future. asyncio.create_task(coro()) wraps a coroutine in a Task, which starts running immediately in the background, not only when you await it.

Key features

  • Awaitable = anything valid after await: a coroutine, a Task, or a Future
  • asyncio.create_task(coro()) schedules coro to start now, returns a Task immediately
  • A Task keeps running even if you never await it again — it is not paused waiting for you
  • task.done() reports whether it finished; awaiting a Task blocks until it does
  • A Future is a lower-level awaitable — most code uses Task, not Future, directly

Task — the methods worth knowing

Task — the methods worth knowing
CallEffect
asyncio.create_task(coro())schedules coro to run now, returns a Task immediately
await taskwaits for the task to finish and returns its result (or re-raises its exception)
task.done()True once the task has finished, been cancelled, or raised
task.result()the return value — raises if the task is not done, or re-raises its exception
task.cancel()requests cancellation; the task receives a CancelledError at its next await point
await coro() vs create_task(coro())

await worker(2)

  • Blocks the caller until worker(2) finishes
  • worker(3) does not even start until then
  • Total time: sum of every wait

create_task(worker(2))

  • Starts running immediately, in the background
  • A second create_task() overlaps with the first
  • Total time: close to the slowest task alone
  • await worker(2)
    • Blocks the caller until worker(2) finishes
    • worker(3) does not even start until then
    • Total time: sum of every wait
  • create_task(worker(2))
    • Starts running immediately, in the background
    • A second create_task() overlaps with the first
    • Total time: close to the slowest task alone

Command

python
task = asyncio.create_task(fetch(url))  # starts running NOW, in the background
# ... do other work here while it runs ...
result = await task                     # wait for it, get the result

await coro() runs one thing and waits; asyncio.create_task(coro()) starts it running in the background immediately, so you can await it later.

3.Scheduling with asyncio.gather

asyncio.gather(*coros) runs several coroutines concurrently and returns their results in the same order they were passed, once all of them finish.

Key features

  • asyncio.gather(*coros) schedules every coroutine concurrently, waits for all to finish
  • Results come back in the SAME ORDER passed in, regardless of which finished first
  • By default, the first raised exception propagates and cancels the other coroutines
  • return_exceptions=True collects exceptions as results instead, so one failure isn't fatal

Command

python
results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))
# results == [result_of_a, result_of_b, result_of_c] -- order preserved

asyncio.gather(*coros) runs everything concurrently and returns results in call order — one raised exception cancels the rest unless return_exceptions=True.

4.Cancellation and timeouts

task.cancel() raises a CancelledError inside the task at its next await point. asyncio.timeout(seconds) does the same thing automatically if a block runs too long, raising TimeoutError instead.

Key features

  • task.cancel() schedules a CancelledError to be raised inside the task at its next await
  • A cancelled coroutine can catch CancelledError to clean up, but should re-raise it
  • asyncio.timeout(seconds) (3.11+) raises TimeoutError if the with block runs too long
  • asyncio.wait_for(coro, timeout) is the older equivalent for a single awaitable
  • A task with no await points cannot actually be interrupted until it reaches one

Command

python
task = asyncio.create_task(long_job())
task.cancel()
try:
    await task
except asyncio.CancelledError:
    ...   # the task was cancelled

async with asyncio.timeout(2):
    await slow_operation()   # raises TimeoutError if it takes over 2s

task.cancel() raises CancelledError at the task's next await; asyncio.timeout(seconds) does the same automatically and raises TimeoutError.

5.Async context managers

async with works like with, but its setup and teardown are coroutines. A class opts in with async def __aenter__ and async def __aexit__ instead of the plain __enter__/__exit__.

Key features

  • async with obj: needs obj to define async __aenter__(self) and async __aexit__(self, ...)
  • __aenter__'s return value becomes the as target, exactly like __enter__
  • contextlib.asynccontextmanager turns an async generator into an async context manager
  • Fits resources whose open/close step is itself I/O — a DB connection, an HTTP session

Command

python
class AsyncResource:
    async def __aenter__(self):
        await self.open()
        return self

    async def __aexit__(self, exc_type, exc, tb):
        await self.close()
        return False

async with AsyncResource() as r:
    await r.use()

async with calls await __aenter__() then guarantees await __aexit__() on exit — the same contract as with, for setup/teardown that needs to await.

6.Async iterators and async generators

async for consumes an async iterator — an object with async def __anext__. An async generator is the easy way to write one: an async def function that uses yield instead of implementing __anext__ by hand.

Key features

  • async for x in obj: needs obj to define __aiter__(self) and async __anext__(self)
  • __anext__ raises StopAsyncIteration — the async equivalent of StopIteration
  • An async def function containing yield is an async generator, built automatically
  • [x async for x in gen] is an async comprehension, like a list comprehension

Command

python
async def countdown(start):
    while start > 0:
        await asyncio.sleep(0.01)
        yield start
        start -= 1

async for n in countdown(3):
    print(n)

values = [n async for n in countdown(3)]  # async comprehension

async def with yield is an async generator; consume it with async for, which awaits between items instead of getting them all at once.

7.Semaphores and async locks

asyncio.Semaphore(n) lets at most n coroutines through a block at once — used to cap concurrency, like limiting simultaneous requests. asyncio.Lock is a semaphore of 1: only one coroutine at a time.

Key features

  • asyncio.Semaphore(n) allows up to n concurrent async with sem: blocks; the rest wait
  • asyncio.Lock() is equivalent to Semaphore(1) — only one coroutine holds it at a time
  • Both are used with async with, which acquires on entry and releases on exit automatically
  • Both protect shared state or a rate-limited resource from too much concurrent access
  • Neither is safe across threads — they coordinate coroutines on one event loop only

Command

python
sem = asyncio.Semaphore(2)  # at most 2 concurrent

async def fetch_page(n):
    async with sem:
        return await do_request(n)

lock = asyncio.Lock()

async def increment(counter):
    async with lock:
        counter["value"] += 1

asyncio.Semaphore(n) caps concurrent access to n coroutines at a time; asyncio.Lock() is the n=1 special case — both used with async with.

8.Blocking code inside async applications

A synchronous blocking call (time.sleep, a non-async DB driver, plain requests.get) inside a coroutine freezes the entire event loop, not just that one task — every other coroutine has to wait too.

Key features

  • time.sleep() or a synchronous requests.get() freezes the whole event loop while it runs
  • await asyncio.sleep() yields control back; time.sleep() blocks the loop's thread instead
  • asyncio.to_thread(fn, *args) runs a blocking call in a thread without freezing the loop
  • Mixing a sync ORM/driver into async code is a common production async mistake
time.sleep() freezes the WHOLE loop, not just its own task
bad_sleep()
event loop
other_task()
  1. 1time.sleep(0.05)blocks the OS thread the loop runs on
  2. 2(cannot switch — thread is blocked)
  3. 3resumes after 0.05s
  4. 4await asyncio.sleep(0.01)only now does other_task get to run
  1. bad_sleep() → event loop: time.sleep(0.05) (blocks the OS thread the loop runs on)
  2. event loop → other_task(): (cannot switch — thread is blocked)
  3. event loop → bad_sleep(): resumes after 0.05s
  4. event loop → other_task(): await asyncio.sleep(0.01) (only now does other_task get to run)

Command

python
# BAD -- blocks the whole event loop
def handler():
    time.sleep(1)

# GOOD -- yields control, other coroutines keep running
async def handler():
    await asyncio.sleep(1)

# GOOD -- for a genuinely blocking call you cannot avoid
async def handler():
    result = await asyncio.to_thread(blocking_function, arg)

A blocking call inside a coroutine freezes the ENTIRE event loop, not just that task — use an async client, or asyncio.to_thread() for calls you cannot avoid.

9.Async HTTP/database clients and connection pooling

A synchronous client (requests, psycopg2) blocks the event loop on every network call. An async client (httpx.AsyncClient, asyncpg) uses await instead, so other coroutines keep running while it waits.

Key features

  • httpx.AsyncClient() is a common async HTTP client — like requests, but awaited
  • asyncpg and SQLAlchemy's async engine are async database clients, for Postgres and others
  • A connection pool keeps open connections ready, avoiding a handshake cost per request
  • Reusing one AsyncClient/pool is far cheaper than opening a connection each call
  • A pool has a max size — beyond it, requests wait for a connection, like a semaphore

Command

python
import httpx

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:   # one pooled client, reused
        responses = await asyncio.gather(*(client.get(u) for u in urls))
    return [r.json() for r in responses]

# asyncpg -- an async Postgres client with a built-in pool
pool = await asyncpg.create_pool(dsn, min_size=5, max_size=20)
async with pool.acquire() as conn:
    rows = await conn.fetch("SELECT * FROM orders WHERE id = $1", order_id)

An async client (httpx.AsyncClient, asyncpg) awaits instead of blocking — reuse one client/pool across requests rather than opening a new connection each call.

10.Backpressure and concurrency limits

Backpressure is deliberately slowing a producer down so it cannot outrun what a consumer or downstream service can handle. A Semaphore or a bounded Queue are the usual tools to enforce a concurrency limit that creates it.

Key features

  • Backpressure = deliberately limiting how fast work is produced, to match the consumer
  • asyncio.Semaphore(n) is the simplest way to enforce a hard concurrency cap
  • asyncio.Queue(maxsize=n) blocks put() once full, until get() removes an item
  • Without a limit, unlimited concurrent requests can exhaust memory or overwhelm a service
  • A concurrency limit is a capacity decision — match it to what downstream can handle

Command

python
queue = asyncio.Queue(maxsize=100)   # producer blocks once 100 items are pending

async def producer():
    for item in source():
        await queue.put(item)   # blocks here if the queue is full -- backpressure

async def consumer():
    while True:
        item = await queue.get()
        await process(item)
        queue.task_done()

Backpressure means slowing a producer to match a consumer — Semaphore or Queue(maxsize=n) enforce it.

11.Graceful cancellation

Graceful cancellation means catching CancelledError just long enough to release resources (close a connection, roll back a transaction), then re-raising it — never swallowing it outright.

Key features

  • A try/finally around awaiting code runs cleanup on cancellation, like any exception
  • Catching CancelledError for cleanup is fine, but it must be re-raised, not swallowed
  • asyncio.shield(coro) protects a coroutine from an outer cancellation reaching it
  • A Task's .cancelled() is True only if CancelledError actually propagated out of it

Command

python
async def worker():
    resource = await acquire()
    try:
        await do_work(resource)
    finally:
        await resource.release()   # runs on success, error, AND cancellation

# or, if cleanup needs to distinguish cancellation specifically:
async def worker():
    try:
        await do_work()
    except asyncio.CancelledError:
        await cleanup()
        raise   # ALWAYS re-raise -- do not swallow it

Use try/finally (or catch CancelledError and re-raise it) so cancellation still runs cleanup — never catch CancelledError without re-raising it.

12.Choosing threading, multiprocessing, or asyncio

Threading suits synchronous I/O-bound work. Multiprocessing suits CPU-bound work needing real parallelism. AsyncIO suits high-concurrency I/O-bound work — thousands of simultaneous waits on one thread.

Key features

  • Threading: mostly I/O-bound synchronous work — existing sync libraries work unmodified
  • Multiprocessing: CPU-bound work — separate processes escape the GIL, pickling data across
  • AsyncIO: high-concurrency I/O-bound work — many connections on one thread, async libraries
  • The GIL makes CPU-bound threading ineffective; it does not slow I/O-bound threading
  • Async and threading are usually alternatives, not combined, for the same I/O-bound problem

Which tool fits which workload

Which tool fits which workload
ToolFitsCost
ThreadingMostly I/O-bound, synchronous code/librariesRace conditions, deadlocks if shared state is not protected
MultiprocessingCPU-bound parallel workPickling every argument/result; higher memory (separate processes)
AsyncIOHigh-concurrency I/O-bound work (many waits at once)Needs async-native libraries end-to-end; one blocking call stalls everything
Workload shape and scale decide the tool
Threading
sync libraries, a handful of concurrent waits
AsyncIO
thousands of concurrent waits, one thread
Multiprocessing
CPU-bound at any scale — escapes the GIL
  • Threading: I/O-bound, Few concurrent ops — sync libraries, a handful of concurrent waits
  • AsyncIO: I/O-bound, Many concurrent ops — thousands of concurrent waits, one thread
  • Multiprocessing: CPU-bound, between Few concurrent ops and Many concurrent ops — CPU-bound at any scale — escapes the GIL

Command

python
# The decision, in order:
# 1. Is the work mostly computing (CPU-bound)?
#      -> multiprocessing (ProcessPoolExecutor)
# 2. Is it I/O-bound with a MODERATE number of concurrent operations,
#    using existing synchronous libraries?
#      -> threading (ThreadPoolExecutor)
# 3. Is it I/O-bound with a LARGE number of concurrent operations
#    (thousands), and async-native libraries are available?
#      -> asyncio

Threading fits sync I/O-bound work; multiprocessing fits CPU-bound work; asyncio fits high-concurrency I/O-bound work — pick by workload shape and scale.

Remember

  • async def call() builds a coroutine object that does nothing until awaited — calling it alone runs no code.
  • create_task() starts a coroutine running immediately in the background; plain await runs one thing and waits for it.
  • A blocking call (time.sleep, a sync DB driver) inside a coroutine freezes the ENTIRE event loop, not just that task.
  • Threading fits sync I/O-bound work, multiprocessing fits CPU-bound work, asyncio fits high-concurrency I/O-bound work.

Pro tip

  • asyncio.gather() preserves call order in its results, regardless of which coroutine finishes first.
  • task.cancel() only interrupts a coroutine at its next await — code with no await points cannot be cancelled mid-stride.
  • Use try/finally (or catch CancelledError and re-raise it) so cancellation still runs cleanup.
  • asyncio.to_thread() runs a genuinely blocking call in a worker thread without freezing the event loop.

Next up

  • Testing

Sources

  • asyncio — docs.python.org/3/library/asyncio.html
  • Every example executed on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.