Skip to content

Async Python

Senior

The event loop and what await really does, tasks and cancellation, races and sync primitives, async generators and context managers, structured concurrency, calling sync code, and debugging async services.

Created 2 September 2026 · Updated 23 October 2026

35 concepts · detail hidden

  1. 01The event loop, mechanically — one iteration over ready callbacks, I/O selectors, and timers

    The event loop is the single central object driving all asyncio execution on one thread. It maintains a queue of callbacks that are ready to run right now, and uses a selector (from the selectors module — a thin wrapper over OS-level mechanisms like epoll on Linux or kqueue on macOS/BSD, which let a program ask the OS “tell me which of these many sockets currently have data ready” without checking each one individually) to know which sockets/file descriptors have become readable or writable. Each iteration (“tick”) of the loop: it runs everything currently in the ready-callback queue; it then computes how long it can safely block waiting on the selector (based on the nearest upcoming scheduled timer — e.g., an asyncio.sleep() call due to wake up); it polls the selector for that duration, waking up early if any I/O becomes ready; then it processes whatever I/O events and expired timers resulted, moving the relevant coroutines back into the ready queue, and loops again. The reason await asyncio.sleep(0) yields control at all, even though it schedules “wake up immediately,” is that it still goes through this same suspend-and-return-to-the-loop mechanism — it forces the current coroutine to pause and give the loop a chance to run anything else that’s also ready, rather than monopolizing execution.

    #c-the-event-loop-mechanically-one-iteration-over-ready-callbac
  2. 02`loop.call_soon` vs `call_later` vs `call_at`

    call_soon schedules a plain (non-async) callback to run as soon as possible, on the very next iteration of the event loop. call_later schedules it to run after a specified relative delay, in seconds, from right now. call_at schedules it to run at a specific absolute time, expressed in terms of the loop’s own internal monotonic clock (via loop.time()), rather than as a delay relative to the current moment.

    #c-loopcall_soon-vs-call_later-vs-call_at
  3. 03Coroutines vs generators under the hood, and what native `async def` (PEP 492) changed

    Yes, at the implementation level — coroutines are built on the exact same frame-suspension machinery that powers generators: a coroutine can pause mid-execution, preserving its local state, and be resumed later from exactly that point. Before native coroutine syntax existed, asyncio code was written using plain generator functions decorated with @asyncio.coroutine, using yield from to delegate to sub-coroutines — functionally similar, but syntactically indistinguishable from an ordinary generator, which made it easy to accidentally misuse a generator as a coroutine or vice versa. PEP 492 (Python 3.5) introduced async def/await as genuinely distinct syntax with its own dedicated type (types.CoroutineType), enforcing stricter rules — a native coroutine object can’t be iterated with a plain for loop the way a generator can, and the two are no longer casually interchangeable — specifically to make the distinction explicit and catch a class of mistakes the old generator-based style allowed. Mechanically, await behaves very similarly to how yield from behaved for delegating to sub-generators in the old style — it suspends the current coroutine, delegates to the awaited thing, and resumes once that’s done.

    #c-coroutines-vs-generators-under-the-hood-and-what-native-asyn
  4. 04What `await` does under the hood — a coroutine is not a thread

    No thread is involved at all — this is the single most common misconception about async/await. A coroutine (an async def function) is a suspendable stack frame (see the previous concept for how that relates to generators) — when you await something, the coroutine’s execution pauses at that exact point, control returns to a single central event loop (a loop, running on one OS thread, that keeps track of many paused coroutines and decides which one to resume next, based on what I/O has become ready or what timers have elapsed), and the event loop goes on to run whatever other work is ready, eventually resuming this coroutine later from exactly where it left off. The critical, commonly-missed consequence: because there’s exactly one thread driving the whole event loop, a blocking call inside a coroutine — time.sleep(), or a synchronous (non-async) library like plain requests.get() — does not “just block that one coroutine”; it blocks the entire thread running the event loop, which means every other coroutine scheduled on that loop is also stalled until the blocking call returns, since nothing else can run on that single thread in the meantime. It’s easy to use asyncio successfully for a while without ever really internalizing this — the fix, when you must call blocking code, is to run it in a separate thread pool (asyncio.to_thread or loop.run_in_executor) so it doesn’t hold the event loop’s one thread hostage (detailed in “Why a blocking call in a coroutine stalls every coroutine on the loop” below). For the deeper mechanics — whether await genuinely “stops” a coroutine, why code after it is guaranteed to see the awaited result rather than a partial one, and exactly how the __await__/Task/Future machinery suspends and resumes a frame — see the next concept, “What makes an object ‘awaitable’”.

    #c-what-await-does-under-the-hood-a-coroutine-is-not-a-thread
  5. 05What makes an object "awaitable" — `__await__` and a minimal custom awaitable

    An object is awaitable — usable with the await keyword — if its class defines __await__, which must itself return an iterator (an object with __next__). This is genuinely lower-level than most people ever need to touch (since async def functions and Task/Future objects already satisfy this protocol for you), but understanding it directly demonstrates real command of the mechanism rather than just fluency with the syntax built on top of it: a minimal custom awaitable might implement __await__ as a generator function that yields some value the event loop interprets as a suspension point, eventually returning a final result — essentially hand-implementing the same suspend/resume contract that async def gives you automatically. Understanding this reduces await to “call __await__ and drive the returned iterator,” rather than treating it as magic syntax.

    Two questions this protocol answers directly, since both are genuinely easy to get wrong: does await “stop” the coroutine it’s in? Yes, precisely at that point — the coroutine’s execution genuinely pauses, mid-function, the same way a generator pauses at yield; nothing after that line runs until something explicitly resumes it. Does it guarantee the code after await only runs once the awaited thing has actually finished? Also yes, unconditionally, within that one coroutine’s own sequential flow — the code after await some_call() is guaranteed to see some_call()'s completed result (or its raised exception), never a partial or still-in-flight state, precisely because the coroutine simply isn’t running again until that’s true. What it does not do is block the thread the event loop runs on — while this one coroutine sits paused, the loop is completely free to run other, unrelated coroutines on that same thread in the meantime (see “What await does under the hood — a coroutine is not a thread” above). “This coroutine’s own continuation always comes strictly after its own await” and “nothing else on the thread is blocked while it waits” are two separate, both simultaneously true guarantees — conflating them is the most common source of confusion here.

    Mechanically, for a low-level awaitable like a Future, __await__ is implemented roughly as:

    def __await__(self):
        if not self.done():
            yield self          # suspend: hand control back up to whoever is driving us
        return self.result()    # resumed: caller already guaranteed this won't raise/block
    

    That bare yield is the actual suspension point — when code does await some_future, control doesn’t return the way it would from an ordinary function call; it unwinds, via that yield, all the way up through every level of await currently on the call stack, back to the Task that’s driving the outermost coroutine with repeated coro.send(value) calls (the same send-driven generator protocol described above). The Task catches that yielded Future, registers a callback on it (add_done_callback), and then itself returns control to the event loop’s own run loop — free, in the meantime, to run other ready callbacks or poll for I/O, exactly as described in “the event loop, mechanically” above. When the awaited operation actually completes, whatever low-level code was driving it calls future.set_result(...), which fires that registered done-callback, which schedules the Task to resume by calling coro.send(result) again. That send re-enters the coroutine’s suspended frame at exactly the point of the yield inside __await__, returns the result, and lets execution continue on the very next line after the original await — locals, current position, and all, exactly like resuming a paused generator.

    #c-what-makes-an-object-awaitable-__await__-and-a-minimal-custo
  6. 06Why a blocking call in a coroutine stalls every coroutine on the loop — and the executor fix

    This is the single most common real-world async Python bug, and the mechanism is simple once stated plainly: there is exactly one OS thread running the event loop — the loop can only make progress on other coroutines by actually regaining control of that thread, which only happens when the currently running coroutine hits an await and voluntarily suspends. A genuinely blocking call (time.sleep(), a synchronous, non-async HTTP library call, or a tight CPU-bound loop with no await inside it) never yields control back — it just occupies the one thread until it finishes on its own — so from the loop’s perspective, nothing else can run at all during that time, regardless of how many other coroutines are conceptually “ready.” The fix: offload the blocking call to a separate thread (via asyncio.to_thread, added in 3.9 as a friendlier wrapper, or the lower-level loop.run_in_executor with a ThreadPoolExecutor) — the event loop’s own thread awaits the result of that background thread’s work, which is a proper suspension point, freeing the loop to service other coroutines while the blocking call runs elsewhere.

    #c-why-a-blocking-call-in-a-coroutine-stalls-every-coroutine-on
  7. 07Whether `print()` or a synchronous file-handler logging call blocks the event loop

    print() is genuinely a blocking, synchronous call — but for typical console/terminal output, it’s fast enough in practice that it essentially never meaningfully stalls the event loop in any way that actually matters. A synchronous logging call using a file handler is more genuinely worth real caution, though: if the underlying disk I/O happens to be slow (a genuinely busy or degraded disk, a network-mounted filesystem), that logging call absolutely can meaningfully block the event loop for a real, non-trivial amount of time, exactly like any other blocking call would.

    #c-whether-print-or-a-synchronous-file-handler-logging-call-blo
  8. 08Why calling `next()` on a sync generator inside a coroutine doesn't make it non-blocking

    Simply calling next() on a genuinely synchronous generator, even from inside an async def coroutine, is still itself a fully synchronous, blocking call — it runs the generator’s own code to completion up through its next yield, with no await point involved anywhere in that process at all — merely being textually inside a coroutine doesn’t retroactively make an underlying synchronous operation non-blocking, echoing the same core lesson as any other blocking call; genuinely non-blocking iteration specifically requires an actual async generator, implementing __anext__ as a real coroutine in its own right.

    #c-why-calling-next-on-a-sync-generator-inside-a-coroutine-does
  9. 09`asyncio.Future` vs `asyncio.Task` vs a bare coroutine object

    Calling an async def function does not run its body at all — it merely constructs and returns a coroutine object, a paused, not-yet-started unit of work. Nothing actually executes until that coroutine is either awaited directly, or wrapped in a Task (via asyncio.create_task, or the lower-level ensure_future) and thereby scheduled onto the event loop for independent execution. A Task is specifically a subclass of Future (a more general “placeholder for a result that will become available later” object, not necessarily tied to a coroutine at all — e.g., low-level callback-based code can use a bare Future directly) that specifically knows how to drive a wrapped coroutine forward step by step until it completes, storing its eventual result or exception. A real, common beginner mistake: creating a coroutine object and simply never awaiting or scheduling it at all — Python detects this and emits a RuntimeWarning: coroutine '...' was never awaited, since the coroutine’s code genuinely never ran.

    #c-asynciofuture-vs-asynciotask-vs-a-bare-coroutine-object
  10. 10Why a `create_task` result you don't hold a reference to can be garbage-collected mid-run

    asyncio.create_task schedules the coroutine to run, but the only thing keeping that Task object alive is whatever holds a reference to the return value — if you discard that return value immediately (a common “fire and forget” pattern), nothing in your code is holding it, and it becomes eligible for garbage collection at any point, potentially mid-execution, silently cutting the task off with no error surfaced anywhere (this is documented, deliberate asyncio behavior specifically to avoid accidentally leaking tasks forever, but its practical effect surprises people). The fix: explicitly retain a strong reference somewhere that outlives the task’s expected duration — a common pattern is keeping a set of in-flight tasks (self._tasks.add(task)), removing each one via a completion callback (task.add_done_callback(self._tasks.discard)) once it finishes — or, in modern code, preferring asyncio.TaskGroup, which structurally guarantees every task it spawns is properly tracked and awaited before the group’s scope exits, sidestepping this whole class of bug.

    #c-why-a-create_task-result-you-dont-hold-a-reference-to-can-be
  11. 11`asyncio.gather` vs `asyncio.wait` vs `asyncio.as_completed` — when to pick each

    gather runs multiple awaitables concurrently and returns their results as a list in the same order they were given (regardless of which actually finished first), and by default propagates the first exception encountered (with return_exceptions=True available to instead collect exceptions alongside successful results rather than raising). wait gives more granular control — you can ask it to return as soon as the first one completes (FIRST_COMPLETED) rather than waiting for all of them, supports an overall timeout, and returns two sets of task objects (done and still-pending) rather than directly handing you results, requiring an extra step to actually pull results out. as_completed takes a different shape entirely: it’s an iterator that yields each awaitable’s result as soon as it finishes, in completion order rather than input order — genuinely useful when you want to start processing whichever results arrive first, rather than waiting for the whole batch, and don’t care about preserving original ordering.

    #c-asynciogather-vs-asynciowait-vs-asyncioas_completed-when-to-
  12. 12Structured concurrency, and how `asyncio.TaskGroup` enforces it vs a manual task list

    Structured concurrency is a design principle: any concurrent tasks spawned within some scope should be guaranteed to finish (successfully, or via being cancelled) before that scope exits, so a task can never silently “outlive” or “escape” the code region that created it, unaccounted for. asyncio.TaskGroup, used as an async with block, enforces exactly this: every task created via the group is guaranteed to be awaited (or explicitly cancelled, if a sibling failed) before the async with block itself completes — there’s no way to exit the block leaving a task dangling. Manually managing a plain list/set of tasks yourself gives no such structural guarantee — nothing prevents a bug where a task outlives the code region that was conceptually “responsible” for it, and TaskGroup additionally collects exceptions from multiple concurrently-failing tasks into an ExceptionGroup rather than only ever surfacing one at a time.

    #c-structured-concurrency-and-how-asynciotaskgroup-enforces-it-
  13. 13How task cancellation works — `CancelledError` thrown in at the next suspension point

    Calling .cancel() on a Task doesn’t stop it immediately — it arranges for a CancelledError to be thrown into the coroutine at its next suspension point (the next await), via the same generator .throw() mechanism used for delivering any exception into a paused generator/coroutine frame. This means cancellation is genuinely cooperative and can be delayed arbitrarily if the coroutine is currently in the middle of a long synchronous stretch of code with no await in it. Once delivered, the coroutine can catch CancelledError like any exception, run cleanup logic, and — while technically possible — deliberately suppressing it (not letting it propagate back out) is almost always the wrong move, since it defeats the cancellation the caller explicitly requested. A specific, important design detail: CancelledError inherits from BaseException, not Exception (since Python 3.8) — precisely so a broad, common except Exception: clause elsewhere in the code doesn’t accidentally swallow a cancellation signal, the same protective reasoning behind KeyboardInterrupt/SystemExit living outside Exception too.

    #c-how-task-cancellation-works-cancellederror-thrown-in-at-the-
  14. 14Cleanup that survives cancellation mid-operation — `try`/`finally` and `await` in `finally`

    Since cancellation can land at literally any await point mid-operation, the standard, reliable pattern is a try/finally wrapped around the entire resource lifecycle — acquire the resource, try the operation, and unconditionally release the resource in finally, which runs regardless of whether the body completed normally, raised a regular exception, or was cancelled. The genuinely subtle part worth knowing: cancellation can, in principle, land on an await inside the finally block itself (e.g., an await connection.close() cleanup call) — a CancelledError can be re-delivered there too, meaning even cleanup code needs to be written defensively if it contains its own await points, rather than assuming finally is somehow immune to further cancellation once it starts running.

    #c-cleanup-that-survives-cancellation-mid-operation-tryfinally-
  15. 15`asyncio.wait_for` vs `asyncio.timeout()` — what the context-manager API solves

    asyncio.wait_for(awaitable, timeout) wraps a single awaitable, cancelling it if the timeout elapses — functional, but composing multiple nested/overlapping timeouts with it (e.g., “this whole operation has 10 seconds total, but this specific sub-step within it has its own 2-second budget”) is awkward, requiring careful manual bookkeeping, and historically had some genuinely subtle bugs around exactly how cancellation propagated in edge cases. asyncio.timeout() (3.11+) is instead a context manager establishing a timeout scope — you can put arbitrary code, including multiple separate awaits, inside an async with asyncio.timeout(10): block, and it composes far more naturally with nested timeout scopes than trying to nest multiple wait_for calls, without needing to restructure your code around wrapping specific individual awaitables.

    #c-asynciowait_for-vs-asynciotimeout-what-the-context-manager-a
  16. 16`asyncio.shield()` — what it's for and the orphaned-task danger

    asyncio.shield(awaitable) protects the inner awaitable from being cancelled if the outer task (the one doing the awaiting) gets cancelled — useful when you have some critical operation that genuinely must run to completion even if the caller gives up waiting on it (e.g., a database write you don’t want interrupted partway through). The danger: the outer await shield(...) call can still raise CancelledError back to the caller (since the outer task was cancelled, even though the inner shielded work keeps running independently) — if nothing else is tracking that now-detached, still-running inner work, it becomes effectively orphaned: still consuming resources and eventually completing (or failing) somewhere in the background, with no code left anywhere actually awaiting its result or handling its eventual outcome, unless you’ve deliberately kept a separate reference to track it.

    #c-asyncioshield-what-its-for-and-the-orphaned-task-danger
  17. 17Race conditions in single-threaded asyncio code

    A race condition is a bug where the correctness of your program depends on the unpredictable relative timing/ordering of operations that could interleave in more than one way. People often assume races are exclusively a multi-threading problem, requiring true parallel execution — but asyncio code, despite running on a single thread with no real parallelism, can still race, because cooperative scheduling (coroutines voluntarily yielding control at await points) still allows other code to run in between two operations that a given coroutine assumed would happen atomically (uninterrupted, as one indivisible unit). Concrete example: a “check, then act” sequence on a shared mutable object (say, a shared dictionary used as a cache) — coroutine A checks whether a key exists, decides it doesn’t, and is about to insert it, but between that check and the insert there’s an await (perhaps a database call to compute the value) — during that await, coroutine B gets scheduled, does its own check, sees the key still doesn’t exist, and also proceeds to compute and insert. Now both insert, possibly clobbering each other’s work or doing redundant expensive computation — a genuine race, entirely without threads or the GIL being involved, purely from interleaving at await boundaries. The lesson: concurrency (multiple logical tasks in progress, possibly interleaved) is not the same thing as parallelism (multiple tasks physically running at the same literal instant) — races require only the former.

    Why asyncio.Lock is still needed

    The fix is the same as with threads — make the check-then-act sequence a critical section — but with asyncio.Lock, whose acquisition is itself await-based (it suspends the waiting coroutine rather than blocking the OS thread):

    import asyncio
    
    cache: dict[str, str] = {}
    lock = asyncio.Lock()
    
    async def get_racy(key):
        if key not in cache:                    # A checks, B checks: both miss
            cache[key] = await fetch_from_db(key)   # both suspend here -> two DB calls
        return cache[key]
    
    async def get_safe(key):
        async with lock:                        # only one coroutine inside at a time
            if key not in cache:
                cache[key] = await fetch_from_db(key)
        return cache[key]
    

    A threading Lock is the wrong tool here: acquiring it while another coroutine on the same thread holds it would block the event loop thread forever (a self-deadlock). Note the corollary: code with no await between the check and the act is atomic with respect to other coroutines, since nothing else can run until the current coroutine suspends — the lock is only needed when the critical section spans an await. For a per-key lock (so unrelated keys don’t serialize), keep a dict[str, asyncio.Lock].

    #c-race-conditions-in-single-threaded-asyncio-code
  18. 18`asyncio.Semaphore` for bounding concurrency vs a time-based rate limiter

    A semaphore is a synchronization primitive holding an internal counter — acquiring it decrements the counter (blocking/suspending if it’s already at zero), releasing it increments the counter back. Using asyncio.Semaphore(10) and having every outbound call async with semaphore: guarantees no more than 10 calls are ever concurrently in-flight at once, regardless of how many total requests (1,000 here) are eventually processed — the semaphore controls concurrency, not throughput over time. This is meaningfully different from a rate limiter capping calls-per-second: a semaphore alone would happily let 10 calls complete near-instantly and immediately start 10 more, potentially far exceeding a provider’s “N calls per second” limit even while staying within the concurrency bound — a real production system calling a rate-limited external API often needs both a concurrency cap (avoiding overwhelming your own resources or connection limits) and a genuine time-based rate limiter (respecting the provider’s actual throughput restriction), and conflating the two is a common, real mistake.

    #c-asynciosemaphore-for-bounding-concurrency-vs-a-time-based-ra
  19. 19`asyncio.Event` vs busy-polling a boolean flag

    asyncio.Event is a simple signaling primitive: any number of coroutines can await event.wait() to pause until some other code calls event.set(), at which point all currently-waiting coroutines are woken up. The alternative — a coroutine looping and repeatedly checking a plain boolean flag with a small sleep in between — is called busy polling, and it’s strictly worse: it wastes CPU cycles re-checking on a fixed schedule regardless of whether anything’s actually changed, and introduces up to that sleep interval’s worth of unnecessary latency between the flag actually changing and the waiter noticing. Event instead integrates with the event loop’s own scheduling — the waiting coroutine is properly suspended (consuming no CPU at all while waiting) and is woken up immediately when set() is called, with no polling delay and no wasted cycles.

    #c-asyncioevent-vs-busy-polling-a-boolean-flag
  20. 20`asyncio.Queue` — bounded producer/consumer backpressure, and the silent consumer-crash failure

    asyncio.Queue(maxsize=N) gives you a queue with a capacity limit — producers calling await queue.put(item) will themselves suspend if the queue is currently full, only proceeding once a consumer removes an item and frees up space; this is exactly what backpressure means — the queue naturally slows down producers to match consumer capacity, rather than letting an unbounded backlog accumulate in memory. The important, easy-to-miss failure mode: if a consumer task crashes (raises an unhandled exception and stops running) while items are still being produced, there is no automatic mechanism telling producers this happened — producers will keep happily putting items into the queue (blocking once it’s full, since nothing is draining it anymore) with no built-in signal that anything’s wrong, potentially deadlocking the whole pipeline silently. Handling this properly requires deliberate design: sentinel “stop” values placed in the queue, task_done()/join() coordination, or explicitly propagating a consumer’s exception back to producers/orchestrating code so the pipeline can shut down cleanly rather than hanging forever.

    #c-asyncioqueue-bounded-producerconsumer-backpressure-and-the-s
  21. 21Async generators and `async for` — `__aiter__`/`__anext__` vs the sync iterator protocol

    An async generator is a function combining async def with yield — each iteration step can itself involve real awaiting (e.g., fetching the next page of results over the network) rather than being purely synchronous computation. async for drives this via __aiter__/__anext__ (the async counterparts of the ordinary iterator protocol’s __iter__/__next__) — critically, __anext__ itself is a coroutine, meaning each step of iteration can suspend and yield control back to the event loop while waiting on I/O, something an ordinary synchronous generator’s __next__ fundamentally cannot do (a plain generator’s __next__ call runs to completion synchronously, with no way to pause mid-step for external I/O). This is the entire reason async generators exist as a distinct construct rather than just reusing regular generators for this purpose.

    import asyncio
    
    async def pages(client, url):
        while url:
            resp = await client.get(url)       # each step can suspend on I/O
            yield resp["items"]
            url = resp.get("next")
    
    async def main(client):
        async for items in pages(client, "/api/orders"):   # awaits __anext__() each step
            print(len(items))
    
        # for items in pages(client, "/api/orders"):  -> TypeError: 'async_generator' object is not iterable
        batches = [items async for items in pages(client, "/api/orders")]   # async comprehension
    

    The two protocols are deliberately disjoint, so a regular for loop over an async generator raises TypeError: 'async_generator' object is not iterable: it looks for __iter__/__next__, finds only __aiter__/__anext__, and in any case has no way to await each step. The consequence is that async for (and async comprehensions) can only appear inside an async def — sync code that needs the values must run a coroutine that consumes the async generator (e.g. via asyncio.run), not iterate it directly.

    #c-async-generators-and-async-for-__aiter____anext__-vs-the-syn
  22. 22Async context managers — why async cleanup needs `__aexit__`, not a sync `__exit__`

    An async context manager implements __aenter__/__aexit__ (both coroutines) instead of the synchronous __enter__/__exit__. Real example: closing a database connection pool often needs to await the actual close operation (it may involve network round-trips to gracefully terminate connections) — a plain, synchronous __exit__ method has no way to await anything, so it’s forced into one of two unsafe choices: fire off the close without waiting for it to actually complete (a real resource leak risk, since you have no confirmation cleanup finished, or ever will), or block the whole thread synchronously waiting for an async operation to finish some other way — which entirely defeats the purpose of being on an async event loop in the first place, since it reintroduces exactly the blocking-call-stalls-everything problem described above.

    #c-async-context-managers-why-async-cleanup-needs-__aexit__-not
  23. 23Why an async generator abandoned mid-iteration leaks its resources — and `aclosing()`

    Breaking out of an async for loop early does not automatically run any cleanup logic that might exist inside the generator (e.g., code positioned after the yield, intended to release a database cursor once iteration finishes) — the generator is simply left suspended, mid-iteration, and its cleanup code never gets a chance to execute unless something explicitly closes it. This is a genuinely underappreciated gotcha even among people who use async generators regularly: the fix is calling .aclose() explicitly on the generator (which delivers a GeneratorExit into it, giving any finally/cleanup logic a chance to run), commonly wrapped via async with contextlib.aclosing(gen): so it happens automatically and reliably even if the loop exits early or an exception occurs, rather than relying on remembering to call .aclose() by hand every time.

    #c-why-an-async-generator-abandoned-mid-iteration-leaks-its-res
  24. 24Calling a synchronous library from async code without blocking the loop — the options

    For a library that’s synchronous but fundamentally I/O-bound under the hood (like an older sync database driver), the right move is offloading the call to a thread pool via asyncio.to_thread (3.9+, a clean, friendly wrapper) or the more general loop.run_in_executor with a ThreadPoolExecutor — since the GIL is released while that thread is blocked on I/O, this genuinely doesn’t block the event loop’s own thread, letting other coroutines continue running while the blocking call happens elsewhere. For something genuinely CPU-bound (heavy parsing/computation, not I/O), a thread pool doesn’t actually help with true parallelism (the GIL still serializes CPU-bound work across threads) — you’d still route it through run_in_executor, but backed by a ProcessPoolExecutor instead, to get genuine multi-core parallelism for the computation itself. The longer-term, more invasive option: migrating to a genuinely async-native version of the library if one exists and the migration effort is justified by the actual performance/scale need.

    #c-calling-a-synchronous-library-from-async-code-without-blocki
  25. 25"The event loop is not thread-safe" — why scheduling from another thread needs `run_coroutine_threadsafe`

    An asyncio event loop’s own internal data structures are genuinely not safe to touch concurrently from multiple OS threads at once — directly calling ordinary loop-scheduling functions from a thread other than the one actually running the loop risks real, genuine data corruption of the loop’s own internal state. asyncio.run_coroutine_threadsafe exists specifically to correctly, safely bridge this gap — it schedules a coroutine to run on the loop’s own thread in a properly synchronized, safe manner, specifically designed to be called from an entirely separate, non-loop thread.

    #c-the-event-loop-is-not-thread-safe-why-scheduling-from-anothe
  26. 26`uvloop` vs the default asyncio event loop — what it changes and when to skip it

    uvloop is an alternative event loop implementation, written largely in Cython (a language that compiles Python-like code down to C for performance) on top of libuv (the same battle-tested C I/O library that powers Node.js) — it’s a drop-in replacement for the default, pure-Python asyncio event loop, and is measurably, significantly faster specifically for I/O-heavy workloads, since it avoids much of the interpretation overhead the default loop’s Python-level implementation carries. Real tradeoffs: it’s historically had weaker or absent support on Windows compared to Linux/macOS; being a separate, less universally battle-tested implementation than the standard library’s own loop, it can occasionally surface subtle incompatibilities with libraries that happen to depend on default-loop-specific internal details; and reaching for it can sometimes mask the fact that your actual bottleneck isn’t the event loop’s own overhead at all (e.g., it’s a slow database query, or a genuinely CPU-bound step) — swapping the loop implementation gives you a speedup on loop overhead specifically, not a general performance fix for whatever else might be slow.

    #c-uvloop-vs-the-default-asyncio-event-loop-what-it-changes-and
  27. 27FastAPI `async def` vs plain `def` endpoints — the thread-pool default and its ceiling

    An async def endpoint runs directly on the event loop like any other coroutine — if it does blocking work internally without properly offloading it, it can stall the loop exactly as described above. A plain, synchronous def endpoint, by contrast, is automatically run in a background thread pool by the underlying Starlette framework FastAPI is built on — this is a deliberate safety default, specifically so an ordinary synchronous function (which has no way to cooperatively yield control the way await does) doesn’t block the entire event loop the way it would if run directly on it. The real bottleneck this default can introduce under load: that thread pool has a finite size — if many concurrent requests hit sync endpoints doing meaningfully slow work simultaneously, they can end up queuing for a free thread-pool slot, becoming a genuine throughput bottleneck distinct from (and easy to overlook relative to) the event loop’s own capacity.

    #c-fastapi-async-def-vs-plain-def-endpoints-the-thread-pool-def
  28. 28Async database driver connection pooling, and why sharing one connection across coroutines corrupts it

    Async database drivers manage a pool of connections much like sync drivers do, but the pool’s checkout/checkin operations are themselves await-based, integrating naturally with async code requesting a connection, using it, and returning it back to the pool for another coroutine to use next — rather than each caller holding onto one fixed connection indefinitely. The real, common bug: if two concurrently-running coroutines end up sharing and using the same single connection object simultaneously (rather than each properly checking one out from the pool independently), their queries can genuinely interleave on the same underlying network socket/protocol state — since a single database connection’s wire protocol generally assumes one request/response conversation happens at a time — corrupting results, causing protocol-level errors, or returning one coroutine’s query results to the other. This surprises people who reasonably (but incorrectly) assume “it’s just I/O, sharing should be fine” — connection objects specifically are not safe to share across concurrently-executing coroutines the way, say, a read-only piece of data would be.

    #c-async-database-driver-connection-pooling-and-why-sharing-one
  29. 29`asyncio.gather` vs `asyncio.TaskGroup` — error handling and structured concurrency

    asyncio.gather(*coros) runs multiple coroutines concurrently and collects their results, but its behavior around partial failure is messy: by default, if one of the gathered coroutines raises, that first exception propagates out of gather immediately, while the other still-running coroutines are not cancelled — they keep running in the background, and if they later fail too, their exceptions go nowhere except an “exception was never retrieved” log line. You can end up with “orphaned” tasks still doing work (and holding connections) that nobody is tracking anymore. Exactly what happens to the siblings has also had version-specific nuance over the years (e.g. what cancelling the gather itself does), which is precisely the kind of ambiguity that motivated structured concurrency.

    asyncio.TaskGroup (Python 3.11, part of structured concurrency — a design philosophy where concurrent tasks are guaranteed not to “escape” the scope that spawned them) fixes this. It’s used as an async with block, and the moment any child task fails, all sibling tasks are actively cancelled, and the group waits for that cancellation to actually complete before the block exits. Every exception that occurred is then collected into an ExceptionGroup (added alongside TaskGroup to represent “more than one exception happened concurrently,” since an ordinary exception can only represent one).

    import asyncio
    
    async def worker(name, delay, fail=False):
        try:
            await asyncio.sleep(delay)
            if fail:
                raise ValueError(name)
            print("finished", name)
        except asyncio.CancelledError:
            print("cancelled", name)
            raise
    
    async def with_gather():
        try:
            await asyncio.gather(worker("a", 0.1, fail=True), worker("b", 0.5))
        except ValueError as e:
            print("gather raised", e)        # raised after 0.1s; "b" is still running
        await asyncio.sleep(1)               # -> later prints "finished b"
    
    async def with_taskgroup():
        try:
            async with asyncio.TaskGroup() as tg:
                tg.create_task(worker("a", 0.1, fail=True))
                tg.create_task(worker("b", 0.5))
        except* ValueError as eg:
            print("group raised", eg.exceptions)   # "cancelled b" printed BEFORE this
    
    gather (default) TaskGroup
    First failure propagates immediately cancels siblings, waits, then raises
    Siblings keep running, untracked cancelled and awaited
    Multiple failures only the first is raised all collected in an ExceptionGroup
    Partial results return_exceptions=True not supported — wrap tasks yourself

    gather(..., return_exceptions=True) is still the right tool when you want every task to run to completion regardless of individual failures and inspect each outcome; TaskGroup is the right default when one failure means the whole operation has failed.

    #c-asynciogather-vs-asynciotaskgroup-error-handling-and-structu
  30. 30`ExceptionGroup` and `except*` (PEP 654) — why structured concurrency needed them

    Ordinary Python exception handling assumes exactly one exception is propagating at a time — but structured concurrency constructs like TaskGroup can have multiple tasks fail concurrently, each with a potentially different exception type, and there was previously no clean way to represent “these several distinct things all went wrong at once” as a single propagating object. ExceptionGroup (PEP 654) solves this: it’s a container exception wrapping a list of the individual exceptions that occurred together. The new except* syntax lets you selectively handle specific exception types out of a group (e.g., except* ValueError: catches only the ValueErrors within the group, letting any other exception types inside it continue propagating, re-wrapped into a new, smaller group) — a genuinely new piece of exception-handling syntax specifically built to support this multi-failure concurrent scenario cleanly.

    #c-exceptiongroup-and-except-pep-654-why-structured-concurrency
  31. 31Profiling a slow async service — blocking work vs I/O contention vs an overloaded loop

    asyncio’s built-in debug mode (enabled via the PYTHONASYNCIODEBUG=1 environment variable, or programmatically) actively warns when a callback takes longer than a configurable threshold (loop.slow_callback_duration) to run — directly surfacing the “something is blocking the loop” symptom rather than making you guess. asyncio.all_tasks() lets you introspect exactly what’s currently scheduled/pending on the loop at a given moment, useful for spotting an unexpectedly large backlog of tasks. py-spy remains valuable here too, letting you sample a live, running process to see what’s actually executing without needing to add instrumentation or restart anything. The key is distinguishing these different symptoms from each other systematically (a slow-callback warning points at blocking code; a large, growing all_tasks() count with individually-fast tasks points more at scheduling/contention overhead) rather than guessing at a fix before diagnosing which failure mode is actually occurring.

    #c-profiling-a-slow-async-service-blocking-work-vs-io-contentio
  32. 32What actually degrades from 1,000 to 100,000 concurrent tasks on one event loop

    Even though coroutines are dramatically lighter-weight than OS threads (no full OS-level stack/context per task, and no OS-level context-switching cost), they’re not entirely free: each Task carries genuine per-task memory overhead (the task object itself, its underlying coroutine’s suspended frame state); the loop’s own internal scheduling overhead grows as the ready-callback queue and pending-task bookkeeping scale up; and — often the more practically significant factor — shared resources many of those tasks are contending over (a bounded database connection pool, a semaphore, a rate limiter) hit real diminishing returns or outright contention well before the loop’s own raw per-task overhead becomes the bottleneck, meaning the practical ceiling in real systems is often set by what those tasks are actually doing, not by the loop’s ability to merely track that many suspended coroutines.

    #c-what-actually-degrades-from-1000-to-100000-concurrent-tasks-
  33. 33Why an async-ified pipeline can be slower than the sync version — accidental serialization and overhead

    A few well-known culprits: the workload might genuinely be CPU-bound, not I/O-bound — async/await machinery adds real overhead (suspending/resuming frames, event loop scheduling) that only pays for itself when there’s actual waiting to overlap; applying it to pure computation just adds cost with no corresponding benefit. Another common cause: too many tiny coroutines, each doing negligible actual I/O — the fixed per-suspension overhead of context-switching through the event loop can end up dominating the total time if each individual unit of async work is too small to be worth that overhead. The single most common real-world cause, though, is accidental serialization: writing code that awaits things one at a time in a loop (for url in urls: result = await fetch(url)) instead of actually gathering them concurrently (await asyncio.gather(*[fetch(url) for url in urls])) — this produces code that looks async but behaves exactly as sequentially as the synchronous version, just with extra overhead layered on top — a subtle enough pattern that it’s easy to miss when reading code that superficially “looks async.”

    #c-why-an-async-ified-pipeline-can-be-slower-than-the-sync-vers
  34. 34Unit-testing `async def` code — `pytest-asyncio`, `AsyncMock`, and event-loop fixture scope

    An async def test function needs to actually be run on an event loop somehow — pytest-asyncio (a pytest plugin) handles this transparently, letting you write async def test_something(): test functions directly and automatically wrapping the necessary event-loop setup/teardown around each one. The subtle bug worth knowing: pytest fixtures (including any event-loop-related fixture) have a configurable scope (function, class, module, session — how broadly one fixture instance is shared/reused across multiple tests, echoing the same tradeoff that applies to regular pytest fixtures) — if an event loop, or a resource tied to a specific event loop, is scoped more broadly than function and gets reused across tests that each expect their own fresh loop, you can hit confusing “this object is attached to a different event loop” errors, or subtle state leakage between tests that should have been fully isolated from each other. Mocking async dependencies specifically requires unittest.mock.AsyncMock (rather than a plain Mock/MagicMock) so that calling the mock actually returns something properly awaitable, matching what real async code expects to receive.

    #c-unit-testing-async-def-code-pytest-asyncio-asyncmock-and-eve
  35. 35Why `asyncio.run()` fails inside a running event loop (e.g. Jupyter), and how to handle it

    asyncio.run() is specifically designed to create and manage a brand-new event loop from scratch, and genuinely cannot be nested inside an already-running one — there’s fundamentally only one event loop actively driving execution on a given thread at a time. Environments like Jupyter notebooks already run their own persistent event loop in the background, so calling asyncio.run() there directly fails; the fix is generally to simply await the coroutine directly instead (since the notebook’s own cell execution is already running inside that active loop), or to use a compatibility library like nest_asyncio specifically designed to permit this kind of nesting.

    #c-why-asynciorun-fails-inside-a-running-event-loop-eg-jupyter-