Choosing the wrong concurrency model for a Python program is worse than choosing none at all. A CPU-bound program rewritten with threading runs slower than the single-threaded original because thread switching adds overhead with no parallelism benefit. An I/O-bound server built with multiprocessing wastes memory on idle processes that spend their time waiting for network responses.
An asyncio application that calls a synchronous blocking library stalls its entire event loop on the first request. The articles in this section have covered threading, multiprocessing, and asyncio individually. This article gives you a repeatable decision process for picking the right one.
The decision reduces to three questions: Is your bottleneck I/O or CPU? How many concurrent operations do you need? What do your dependencies support?
Answer these three questions, and the right model follows directly.
The decision flowchart
The fastest way to choose is to follow this sequence of questions. Each answer narrows the options until one remains.
Step 1: Is your program I/O-bound or CPU-bound?
If you do not know, profile it. Use cProfile on your synchronous code, or add time.perf_counter() around the sections you suspect are slow. If the program spends most of its time in time.sleep(), socket.recv(), requests.get(), file.read(), or database query calls, it is I/O-bound.
If it spends most of its time in loops, arithmetic, string processing, or data transformation, it is CPU-bound.
Step 2 (I/O-bound): How many concurrent operations?
If fewer than roughly 50 concurrent operations, concurrent.futures.ThreadPoolExecutor is the simplest correct choice. It works with any synchronous library and the API is straightforward.
If hundreds or thousands of concurrent operations, asyncio is the right choice. The per-task overhead of threads becomes significant at scale, and asyncio's cooperative multitasking handles many tasks more efficiently.
Step 3 (CPU-bound): Can you optimize the algorithm first?
Before reaching for multiprocessing, check whether the computation can be made faster. Use a more efficient algorithm. Use NumPy for numeric work.
Use a C extension or Cython for hot loops. Multiprocessing adds process management and serialization overhead. If a faster single-threaded implementation already meets your performance target, you do not need multiprocessing.
If optimization is not enough, use concurrent.futures.ProcessPoolExecutor. It gives true parallelism across CPU cores with a clean API.
Step 4: What do your dependencies support?
If your HTTP client, database driver, or message queue library is sync-only, threading is the path of least resistance. Wrapping every synchronous call in run_in_executor() from asyncio works but adds complexity. If your framework is async-native (FastAPI, aiohttp, Starlette), asyncio is the natural choice.
Detailed comparison table
| Criterion | Threading | Asyncio | Multiprocessing |
|---|---|---|---|
| Best for | I/O-bound, moderate concurrency | I/O-bound, high concurrency | CPU-bound |
| Parallel Python execution | No (GIL) | No (single thread) | Yes (separate interpreters) |
| Memory per worker | ~8 MB stack | A few KB | Full process (~20+ MB) |
| Startup time | ~1 ms | Microseconds | ~50-200 ms |
| Library compatibility | All sync libraries | Async libraries required | All libraries (in each process) |
| Shared state | Direct (needs locks) | Not shared (needs message passing) | Explicit only (serialization) |
| Error propagation | Manual | Exception on await | Exception on result() |
| Cancellation | No safe kill | Cooperative (CancelledError) | terminate() (unsafe) |
| Scaling limit | Hundreds of threads | Tens of thousands of tasks | CPU core count |
Scenarios with recommended models
Scenario 1: Web scraper for 50 URLs
You have a list of 50 URLs and need to fetch and parse each page. The bottleneck is HTTP response time, roughly 200 ms per URL.
Recommended: ThreadPoolExecutor with max_workers=10.
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch_and_parse(url):
response = requests.get(url, timeout=10)
return len(response.text)
urls = ["https://example.com"] * 50
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch_and_parse, urls))Threading is simplest because requests is synchronous and 50 URLs do not justify the complexity of asyncio. Ten workers keep concurrency manageable while providing significant speedup over sequential execution.
Scenario 2: Production REST API serving 10,000 requests/second
You are building a backend service that receives requests, queries a database, and returns JSON. The database queries dominate latency.
Recommended: asyncio with FastAPI and an async database driver.
from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncpg
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = await asyncpg.create_pool(dsn="...")
yield
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM users WHERE id = $1", user_id
)
return dict(row)Asyncio handles thousands of concurrent database queries without the per-connection thread overhead. FastAPI integrates natively with asyncio, and asyncpg is a high-performance async PostgreSQL driver.
Scenario 3: Image processing pipeline
You have a directory of 10,000 images and need to resize, watermark, and compress each one. The bottleneck is CPU: each image takes roughly 100 ms of computation.
Recommended: ProcessPoolExecutor with max_workers=os.cpu_count().
from concurrent.futures import ProcessPoolExecutor
import os
from PIL import Image
def process_image(filepath):
img = Image.open(filepath)
img = img.resize((800, 600))
img.save(filepath.replace(".jpg", "_processed.jpg"))
if __name__ == "__main__":
files = ["image_001.jpg"] * 100
with ProcessPoolExecutor() as executor:
list(executor.map(process_image, files))Multiprocessing uses all CPU cores for true parallelism. Threading would not help because the GIL prevents parallel image processing. Asyncio would not help because there is no I/O to overlap.
Scenario 4: Mixed workload with async API and CPU computation
You have a FastAPI endpoint that fetches data from an external API, then runs a CPU-intensive analysis on the result.
Recommended: asyncio for the I/O layer, run_in_executor with ProcessPoolExecutor for the CPU work.
import asyncio
from concurrent.futures import ProcessPoolExecutor
def analyze(data):
return sum(i * i for i in range(data))
async def endpoint_handler(raw_data):
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
ProcessPoolExecutor(), analyze, raw_data
)
return {"result": result}The event loop stays responsive while the CPU work runs in a separate process. Other requests can be handled concurrently during the analysis.
Scenario 5: Long-running background monitor
You need a background thread that checks system health every 30 seconds and sends metrics. It runs for the lifetime of the application.
Recommended: A single daemon threading.Thread.
import threading
import time
def health_monitor():
while True:
check_health()
send_metrics()
time.sleep(30)
monitor = threading.Thread(target=health_monitor, daemon=True)
monitor.start()A single daemon thread is simpler than asyncio for a long-lived periodic task that does not need high concurrency. It does not require an event loop and will not conflict with the rest of the application's concurrency model.
When not to add concurrency at all
Concurrency adds complexity, and complexity creates bugs. Before adding any concurrency model, verify that your program actually needs it:
- Does the synchronous version meet your performance requirements? If yes, stop. Concurrency is not free.
- Can you reduce the work instead of parallelizing it? Caching, indexing, and query optimization often produce larger gains than concurrency.
- Is the bottleneck external? If your database is the bottleneck, adding more concurrent Python workers just increases contention. Fix the database first.
The best concurrency model is the one you do not need. When you do need it, the three-question flowchart and the scenario examples in this article give you a reliable path to the right choice.
Rune AI
Key Insights
- I/O-bound + few concurrent tasks: ThreadPoolExecutor for simplicity.
- I/O-bound + thousands of concurrent tasks: asyncio for efficiency.
- CPU-bound: ProcessPoolExecutor for true parallelism.
- Mixed I/O and CPU: asyncio with run_in_executor(ProcessPoolExecutor).
- Always profile before choosing; the wrong model is worse than no concurrency at all.
Frequently Asked Questions
Can I use all three concurrency models in the same Python program?
Which concurrency model should I learn first?
What is the simplest concurrency model for a beginner?
Conclusion
The right concurrency model is the one that matches your workload. I/O-bound with moderate concurrency: ThreadPoolExecutor. I/O-bound with thousands of concurrent tasks: asyncio. CPU-bound: ProcessPoolExecutor. Mixed: asyncio with run_in_executor for CPU work. The flowchart and tables in this article give you a repeatable decision process. Combine it with profiling your actual workload, and you will make the right choice far more often than not.1. Profile your synchronous code. Identify whether the bottleneck is I/O or CPU. 2. Estimate your concurrency needs: dozens, hundreds, or thousands of concurrent operations. 3. Check your dependencies: sync-only or async-compatible. 4. Choose: ThreadPoolExecutor for simple I/O, asyncio for high-concurrency I/O, ProcessPoolExecutor for CPU. 5. Implement the simplest version that meets requirements. Benchmark. Adjust only if needed. The next articles cover common concurrency mistakes so you can avoid the pitfalls that trip up developers regardless of which model they choose, and performance tips for concurrent Python to get the most out of your chosen approach.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.