Python Cheatsheet
Async and asyncio
Use this Python reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
async def, await, asyncio.run
async def defines a coroutine function. Calling it runs nothing, it returns
a coroutine object that only makes progress when something awaits it. await
suspends the current coroutine and lets the event loop run other work.
import asyncio async def fetch(n: int) -> int: await asyncio.sleep(0.1) # yields control, does NOT block the loop return n * 2 fetch(1) # <coroutine object fetch at 0x...> # RuntimeWarning: never awaited async def main() -> None: result = await fetch(1) # 2 print(result) asyncio.run(main()) # the one entry point, creates and closes a loop
Rules that bite immediately:
- await is only legal inside async def.
- asyncio.run cannot be called from inside a running loop. In Jupyter, just
await main(), the loop is already running.
- A forgotten await gives you a coroutine object where you expected a value,
plus a RuntimeWarning at garbage-collection time.
- Do not create loops by hand. get_event_loop() outside a coroutine is
deprecated, asyncio.run or asyncio.Runner (3.11+) is the answer.
Sequential await Is Not Concurrency
Awaiting in a loop is exactly as slow as synchronous code. Concurrency starts when you hand several awaitables to the loop at once. Ten 0.1s calls, measured:
| Form | Wall time |
|---|---|
for i in range(10): await work(i) | 1.01 s |
await asyncio.gather(*(work(i) for i in range(10))) | 0.10 s |
# gather: ordered results, first exception cancels nothing and propagates results = await asyncio.gather(fetch(1), fetch(2), fetch(3)) # [2, 4, 6] # return_exceptions keeps the good results and hands you the failures results = await asyncio.gather(*coros, return_exceptions=True) oks = [r for r in results if not isinstance(r, Exception)]
TaskGroup (3.11+, the preferred form)
TaskGroup waits for every child, and if one fails it cancels the siblings and
raises an ExceptionGroup. That is structured concurrency, and it is why it beats
gather, which leaves siblings running on failure.
async def main() -> None: async with asyncio.TaskGroup() as tg: t1 = tg.create_task(fetch(1)) t2 = tg.create_task(fetch(2)) # both are done here, results available print(t1.result(), t2.result()) # Handle grouped failures with except* (3.11+) try: async with asyncio.TaskGroup() as tg: tg.create_task(may_fail()) tg.create_task(may_also_fail()) except* ValueError as eg: print("value errors:", eg.exceptions) except* TimeoutError as eg: print("timeouts:", eg.exceptions)
except* clauses all run, not just the first match. An ExceptionGroup cannot be
caught by a plain except ValueError, so retro-fitting TaskGroup into old code
means updating the handlers.
create_task and the Reference Gotcha
create_task schedules a coroutine immediately and returns a Task. The loop
only keeps a weak reference, so a task nobody holds can be garbage collected
mid-flight and silently disappear.
# WRONG, the task may vanish asyncio.create_task(background_work()) # Right, keep a reference task = asyncio.create_task(background_work(), name="worker") ... await task # Right for fire-and-forget, hold a strong set _background: set[asyncio.Task] = set() def spawn(coro): t = asyncio.create_task(coro) _background.add(t) t.add_done_callback(_background.discard) # and this surfaces exceptions # Best: use a TaskGroup and let it own the lifetime
Task inspection: t.done(), t.result() (re-raises the exception),
t.exception(), t.cancel(), t.cancelled(), t.get_name().
Never call t.result() before it is done, that raises InvalidStateError.
Timeouts, as_completed, Queues, Locks
# asyncio.timeout: a manager, wraps a whole block (3.11+), the modern choice try: async with asyncio.timeout(5): await step_one() await step_two() except TimeoutError: # builtin TimeoutError since 3.11 ... async with asyncio.timeout_at(loop.time() + 10): # absolute deadline ... # wait_for: one awaitable, cancels it on expiry result = await asyncio.wait_for(fetch(1), timeout=0.5) # Results in completion order, not argument order for future in asyncio.as_completed([fetch(1), fetch(2), fetch(3)]): print(await future) # wait: full control, returns (done, pending) done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) for t in pending: t.cancel() # Queue: the standard producer/consumer pipeline, maxsize gives backpressure q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=100) await q.put(item) item = await q.get() q.task_done() await q.join() # wait until every put item is task_done # Synchronization, all async context managers lock = asyncio.Lock() sem = asyncio.Semaphore(10) # cap concurrency, the usual rate limiter event = asyncio.Event() async with sem: # never `with`, that is a TypeError await hit_api() await event.wait() event.set()
Use asyncio.Lock, not threading.Lock, inside a loop. The threading version
blocks the whole loop instead of yielding.
Cancellation
task.cancel() raises CancelledError at the task's current await point.
CancelledError inherits from BaseException, so except Exception does not
catch it. If you catch it, re-raise it, otherwise you break cancellation for
everyone above you.
async def worker(): try: await long_running() except asyncio.CancelledError: await flush_partial_state() # cleanup raise # ALWAYS re-raise finally: await release() task = asyncio.create_task(worker()) task.cancel() try: await task # wait for it to actually finish except asyncio.CancelledError: pass # Shield work that must not be cancelled (use sparingly) await asyncio.shield(commit_transaction())
asyncio.timeout and a failing TaskGroup both cancel by way of this mechanism,
so a swallowed CancelledError shows up as a timeout that never fires.
One Blocking Call Freezes Everything
There is a single thread. time.sleep, requests.get, open().read(), a pandas
operation, or a tight CPU loop stops every coroutine for its whole duration.
A time.sleep(0.2) inside a coroutine measurably added 0.2 s to the loop's wall
clock in a run that should have taken 0.05 s.
import asyncio, time # WRONG async def bad(): time.sleep(1) # the entire program is frozen for 1s data = requests.get(url).text # same problem # Right: push blocking I/O to a thread (3.9+) async def good(): data = await asyncio.to_thread(requests.get, url) text = await asyncio.to_thread(path.read_text) # Right: an explicit executor, and the only way to reach processes from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor loop = asyncio.get_running_loop() with ThreadPoolExecutor(max_workers=8) as pool: result = await loop.run_in_executor(pool, blocking_io, arg) with ProcessPoolExecutor() as pool: # CPU-bound work, dodges the GIL result = await loop.run_in_executor(pool, heavy_math, arg)
to_thread passes arguments through and is the one-liner. run_in_executor takes
a positional-only callable (use functools.partial for keywords) and is what you
need for a ProcessPoolExecutor.
asyncio vs threading vs multiprocessing
| asyncio | threading | multiprocessing | |
|---|---|---|---|
| Good for | many slow I/O waits | blocking libraries, some I/O | CPU-bound work |
| Parallel CPU | no | no (GIL) | yes |
| Unit cost | ~KB per task | ~MB stack per thread | a whole interpreter |
| Practical scale | 10k+ tasks | hundreds | one per core |
| Switch points | only at await | anywhere, preemptive | separate processes |
| Data sharing | plain variables, one thread | shared memory plus locks | pickle, Queue, shared memory |
| Race conditions | far fewer, still possible | the usual nightmare | isolated by default |
The GIL lets one thread execute Python bytecode at a time, which is why threads do
not speed up computation. The 3.13 free-threaded build (python3.13t, PEP 703)
removes it, but it is experimental, opt-in, single-threaded slower, and most C
extensions are not ready.
A Real Concurrent Fetch
import asyncio, httpx async def fetch_one(client: httpx.AsyncClient, url: str, sem: asyncio.Semaphore) -> tuple[str, int]: async with sem: # cap in-flight requests r = await client.get(url, timeout=10.0) return url, r.status_code async def fetch_all(urls: list[str], limit: int = 20) -> list[tuple[str, int]]: sem = asyncio.Semaphore(limit) async with httpx.AsyncClient(http2=True) as client: # ONE client, reused async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(fetch_one(client, u, sem)) for u in urls] return [t.result() for t in tasks] results = asyncio.run(fetch_all(urls))
# aiohttp is the other common choice import aiohttp async with aiohttp.ClientSession() as session: async with session.get(url) as resp: payload = await resp.json()
Create one client or session for the whole run. A per-request client throws away connection pooling and is usually slower than synchronous code.
Async Iterators, Generators, and Managers
# async generator: async def + yield async def paginate(client, url): while url: page = await client.get(url) for item in page["items"]: yield item url = page.get("next") async for item in paginate(client, "/api/items"): process(item) # Async comprehension rows = [r async for r in paginate(client, url) if r["ok"]] # The protocol behind it class Ticker: def __aiter__(self): return self async def __anext__(self): await asyncio.sleep(1) if self.done: raise StopAsyncIteration return self.tick() # Close an async generator deterministically from contextlib import aclosing async with aclosing(paginate(client, url)) as gen: async for item in gen: ...
Debugging
asyncio.run(main(), debug=True) # warns on slow callbacks and never-awaited coros
PYTHONASYNCIODEBUG=1 python app.py python -X dev app.py # dev mode, same warnings plus more
# What is running right now for t in asyncio.all_tasks(): print(t.get_name(), t.get_coro()) asyncio.current_task().get_name() # Full stack of every task, the fastest way to find a hang for t in asyncio.all_tasks(): t.print_stack() # python -m asyncio (3.8+) is an async REPL where top-level `await` works
Common symptoms: "coroutine was never awaited" means a missing await, "Event
loop is closed" means work outlived asyncio.run, "Task was destroyed but it is
pending" means you did not await or cancel a task before shutdown.