Choosing a Concurrency Model
Pick asyncio, threads, or processes based on workload shape, library APIs, and operability - not hype. This checklist walks from profiling data to a defensible choice.
Search across all documentation pages
Pick asyncio, threads, or processes based on workload shape, library APIs, and operability - not hype. This checklist walks from profiling data to a defensible choice.
py-spy, cProfile, APM) - labels lie.to_thread.ProcessPoolExecutor; NumPy/C extensions may release GIL in threads.| Profile | First choice | Fallback |
|---|---|---|
| Many HTTP/DB waits, async libs | asyncio | ThreadPool + blocking libs |
| Blocking SDK, moderate concurrency | ThreadPoolExecutor | asyncio + to_thread |
| CPU pure Python | ProcessPoolExecutor | C extension / vectorize |
| Mixed API + CPU stage | asyncio + process pool | Thread pool + process pool split |
| Simple script, <5 tasks | Sequential | threads if I/O slow |
If yes and async-capable libraries exist, prefer asyncio for hundreds+ concurrent waits. If libraries are blocking, use ThreadPoolExecutor sized to connection limits.
If yes on default GIL build, use multiprocessing or ProcessPoolExecutor. Do not expect thread speedup for tight loops.
Split pipeline: async fetch -> asyncio.to_thread or run_in_executor(ProcessPool) for transform. Keep CPU work off the event loop thread.
Windows spawn requires picklable workers; daemon threads complicate graceful shutdown; process memory = baseline × workers. Factor into choice.
Compare p50/p99 latency and CPU under realistic load. Wrong model shows as event-loop blockage, GIL contention, or process spawn thrash.
asyncio - framework is async-native; offload blocking to thread pool sparingly.
Low QPS admin scripts, cron with single connection, prototypes before scale proof.
Yes via executor - never call blocking I/O directly on loop thread.
Legacy monkey-patch model - prefer asyncio for greenfield unless stuck on sync stack.
Connection pool size or concurrent blocking calls - not CPU count.
Avoid - task must amortize spawn/pickle cost.
Heavy ops run in native code - often release GIL; still profile, may stay in-process threads.
May shift CPU-bound thread choice - re-run checklist after extension audit.
Process-based workers are separate choice - often multiprocessing by design for isolation.
ADR: workload, measured profile, chosen model, rejected alternatives, revisit trigger.
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.
Reviewed by Chris St. John·Last updated Jul 19, 2026