
Why this matters
Backend services spend most of their time waiting – on HTTP calls, database queries, or file reads. Python’s asyncio solves this, but rewriting everything into async/await is invasive. For straightforward “fire N requests in parallel” jobs, concurrent.futures.ThreadPoolExecutor gives you real concurrency with plain synchronous code.
The pattern
Submit each unit of work with executor.submit(), collect the Future objects, then read results with as_completed() so you can process each one as soon as it finishes:
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
urls = [
"https://api.github.com/repos/python/cpython",
"https://api.github.com/repos/psf/requests",
"https://api.github.com/repos/pallets/flask",
]
def fetch(url):
resp = requests.get(url, timeout=10)
return url, resp.status_code, resp.json().get("stargazers_count", 0)
with ThreadPoolExecutor(max_workers=5) as pool:
futures = {pool.submit(fetch, url): url for url in urls}
for fut in as_completed(futures):
url, status, stars = fut.result() # raises if fetch() failed
print(f"{url} -> {status} ({stars} stars)")
I/O-bound vs CPU-bound
Threads only help when you are waiting, not computing. Pick the executor to match the workload:
- I/O-bound (HTTP, DB, disk):
ThreadPoolExecutor– the GIL is released while waiting, so threads truly run in parallel. - CPU-bound (parsing, math, image processing):
ProcessPoolExecutor– each worker gets its own interpreter and real parallelism. - Using threads for CPU work is a classic mistake: the GIL serializes them and you gain nothing.
Gotcha: exceptions are deferred
A task that raises inside the pool does not crash your program immediately. The exception is stored on the Future and only re-raised when you call .result(). If you never call it, the error is silently swallowed:
with ThreadPoolExecutor() as pool:
fut = pool.submit(lambda: 1 / 0)
# nothing happens here...
fut.result() # ZeroDivisionError raised now
Always call .result() (directly or via as_completed) on every future, or wrap it in try/except to handle failures per-task instead of aborting the whole batch.