skills/python-asyncio-pitfalls/SKILL.md
Use when debugging asyncio event-loop hangs, blocking calls inside coroutines, asyncio.gather vs TaskGroup choice, cancellation handling, structured concurrency, mixing sync and async, run_in_executor decisions, asyncio task lifetimes, "Task was destroyed but it is pending" warnings, or async context manager bugs. Triggers: event loop blocked by sync I/O, ConnectionResetError on cancellation, asyncio in libraries that also offer sync API, FastAPI/aiohttp performance regressions, queue.Queue used in async code. NOT for trio/anyio (different paradigm), threading without asyncio, or Python 2 sync patterns.
npx skillsauth add curiositech/windags-skills python-asyncio-pitfallsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Asyncio is cooperative — one blocking call freezes the entire event loop. Most "asyncio is slow" stories are actually "we accidentally called a synchronous function inside a coroutine." This catalogs the traps.
RuntimeError: This event loop is already running or "Task was destroyed but it is pending".asyncio.gather, asyncio.TaskGroup (3.11+), and asyncio.wait.import asyncio
async def main():
loop = asyncio.get_running_loop()
loop.set_debug(True) # warns when a coroutine takes too long
# PYTHONASYNCIODEBUG=1 also enables debug mode at the env var level
Debug mode logs:
Executing <Task ... took 0.250 seconds
For production, instrument with loop.slow_callback_duration = 0.1. Anything over 100ms means a blocking call slipped in.
async def fetch_all(urls: list[str]) -> list[str]:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(u)) for u in urls]
return [t.result() for t in tasks] # exceptions raised here
If any task raises, all siblings are cancelled and the exception (or ExceptionGroup) propagates out. This is the safe default.
asyncio.gather semantics:
results = await asyncio.gather(fetch(a), fetch(b), return_exceptions=True)
# Without return_exceptions=True, the first exception cancels nothing — siblings keep running.
Use gather only when you want the older fire-and-collect-errors semantics; TaskGroup for everything else.
# WRONG — blocks the loop.
async def slow():
return time.sleep(2) # NOT awaitable; runs sync, blocks loop
# RIGHT — offload to a thread.
async def slow():
return await asyncio.to_thread(time.sleep, 2)
asyncio.to_thread (3.9+) runs the function in a thread from the default executor. For CPU-bound work, use a ProcessPoolExecutor and loop.run_in_executor:
import concurrent.futures
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, expensive_pure_function, arg)
async def with_cleanup():
try:
await long_operation()
except asyncio.CancelledError:
await cleanup()
raise # re-raise so the caller knows you were cancelled
return result
Swallowing CancelledError is a common cause of "task was destroyed but it is pending" warnings. Always re-raise unless you're explicitly catching to ignore.
import contextvars
request_id = contextvars.ContextVar('request_id')
async def handler(rid: str):
request_id.set(rid)
# Spawned tasks inherit ContextVar values automatically.
asyncio.create_task(log_async())
Use ContextVars for request-scoped data instead of thread-locals; threading.local doesn't work right in asyncio.
async def stream_chunks(reader: asyncio.StreamReader):
while not reader.at_eof():
chunk = await reader.read(4096)
if not chunk:
break
yield chunk
async for chunk in stream_chunks(reader):
process(chunk)
async for is the right way to consume async iterators; pulling them with __anext__ directly is rarely correct.
# 3.11+ — preferred
async with asyncio.timeout(5):
result = await long_call()
# Older — wait_for
result = await asyncio.wait_for(long_call(), timeout=5)
asyncio.timeout integrates with TaskGroup; wait_for doesn't and can cause a double-cancel surprise.
asyncio.Queue is the right choice; queue.Queue is sync and would block.
queue: asyncio.Queue[Job] = asyncio.Queue(maxsize=100)
async def producer():
while item := await source():
await queue.put(item)
async def consumer():
while True:
job = await queue.get()
try:
await process(job)
finally:
queue.task_done()
await queue.join() # wait until all submitted jobs marked done
maxsize provides backpressure — without it, a fast producer can OOM you.
lock = asyncio.Lock()
async with lock:
await critical_section()
Don't share an asyncio.Lock across event loops; each loop has its own.
async def route handlers when you have async I/O. Sync handlers run in a threadpool — fine, but you pay the thread overhead.httpx.AsyncClient, asyncpg pool) belong at app startup, shared across requests. Don't construct per-request.BackgroundTasks (FastAPI) for fire-and-forget; asyncio.create_task if you need to track.Symptom: Throughput craters under load; one slow request blocks everything.
Diagnosis: requests.get, time.sleep, psycopg2.execute — all sync, all block the loop.
Fix: Switch to async libraries (httpx.AsyncClient, asyncio.sleep, asyncpg). For sync libraries you can't replace, asyncio.to_thread.
Symptom: coroutine 'fn' was never awaited warning. No error, no return value.
Diagnosis: result = fn() instead of result = await fn().
Fix: Add the await. Linters (ruff, pylint) flag this; turn the rule on.
asyncio.gather without return_exceptions=True when you want to wait for allSymptom: Partial work; some tasks ran, others mysteriously didn't.
Diagnosis: First exception propagates and the siblings are NOT cancelled — they keep running detached.
Fix: Use TaskGroup (cancels siblings cleanly) or gather(*coros, return_exceptions=True) followed by manual handling.
CancelledErrorSymptom: "Task was destroyed but it is pending" warning at shutdown. Diagnosis: A task caught CancelledError without re-raising; the runtime never finished cleanup. Fix: Always re-raise after cleanup. Use try/finally for cleanup that must run regardless.
Symptom: RuntimeError: This event loop is already running or sporadic data corruption.
Diagnosis: Calling loop.run_until_complete from a thread other than the loop's owner.
Fix: Use asyncio.run_coroutine_threadsafe(coro, loop) from non-loop threads.
asyncio.QueueSymptom: OOM under burst load.
Diagnosis: Producer faster than consumer; queue grows unbounded.
Fix: Set maxsize on the queue. Producer awaits put and naturally blocks.
ASYNC1* rules).TaskGroup used for fan-out where any failure should cancel siblings.asyncio.timeout(...) (or wait_for) wraps every external call.CancelledError always re-raised after cleanup.asyncio.Queue(maxsize=…) has explicit backpressure.loop.set_debug(True) enabled in dev; slow_callback_duration alerts in prod.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.