Speed Up Python I/O with concurrent.futures
Learn how to use Python's built-in concurrent.futures module to run I/O-bound tasks like file reads and API calls in parallel. This practical guide shows how to cut execution time by 80–90% with minimal code changes.
Speed Up Your Python I/O: A Practical Guide to concurrent.futures
Ever watched your Python script crawl through a list of files or API calls, processing them one by one? It’s like being stuck behind a slow driver on a one-lane road. But here’s the thing: most I/O tasks—whether reading files, downloading images, or hitting web endpoints—don’t actually need the CPU. They’re just waiting. And waiting.
That’s where concurrent.futures comes in. It’s Python’s built-in way to run multiple I/O tasks without waiting for each to finish. No complex threading or multiprocessing setup required. Just clean code that runs noticeably faster.
What Makes concurrent.futures Different?
Think of it this way: your regular code is like ordering coffee one cup at a time. You wait for each cup to be made before ordering the next. With concurrent.futures, you place all orders at once and pick them up as they’re ready.
The module gives you two main tools: ThreadPoolExecutor for I/O-bound tasks (like network requests or file operations) and ProcessPoolExecutor for CPU-heavy work. For I/O, threads are usually the right choice because the GIL (Global Interpreter Lock) isn’t a problem when your code is mostly waiting.
Starting Simple: The Sequential Way
Before we parallelize, let’s see what normal code looks like. Say you’re reading a batch of text files:
import time
from pathlib import Path
files = [f"data_{i}.txt" for i in range(5)]
def read_file(filename):
time.sleep(2) # Simulating file I/O delay
content = filename + " loaded"
print(f"Finished reading {filename}")
return content
start = time.time()
results = [read_file(f) for f in files]
end = time.time()
print(f"Total time: {end-start:.2f} seconds")
This takes about 10 seconds (5 files × 2 seconds each). Painfully slow, right?
The concurrent.futures Makeover
Now watch how three lines change everything:
from concurrent.futures import ThreadPoolExecutor, as_completed
start = time.time()
with ThreadPoolExecutor(max_workers=5) as executor:
future_to_file = {executor.submit(read_file, f): f for f in files}
results = []
for future in as_completed(future_to_file):
result = future.result()
results.append(result)
print(f"Got: {result}")
end = time.time()
print(f"Total time: {end-start:.2f} seconds")
Same task. Same function. But now it runs in about 2 seconds—all files processed simultaneously. The max_workers=5 means we’re handling five files at once, so the total time drops to just the longest single operation.
Real World Example: API Calls
Let’s make it practical. Imagine you work at PythonSkillset.com and need to check the status of 20 user accounts by calling an external API:
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
user_ids = range(1, 21)
def check_user_status(user_id):
# Simulating an API call that takes 0.5-1.5 seconds
import random
time.sleep(random.uniform(0.5, 1.5))
return {"user": user_id, "status": "active" if user_id % 3 else "suspended"}
# Sequential - takes about 20 seconds
# results = [check_user_status(uid) for uid in user_ids]
# Parallel - takes about 1.5 seconds
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(check_user_status, uid) for uid in user_ids]
for future in as_completed(futures):
result = future.result()
print(f"User {result['user']}: {result['status']}")
For a real API, you’d replace the sleep with requests.get(). The same pattern works for downloading images, processing log files, or any I/O-bound task.
When Not to Use Threads for I/O
Threads aren’t magical. Avoid them when:
- Your I/O operations involve shared state that’s not thread-safe (like writing to the same file)
- You’re doing CPU-intensive work inside the I/O tasks (use ProcessPoolExecutor instead)
- You need strict ordering of results (but as_completed helps)
The Submit vs Map Decision
You saw submit above, which gives you fine control. There’s also executor.map() for simpler cases:
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(read_file, files))
map() keeps the order of results matching the input, but you don’t see partial progress. submit with as_completed shows results as they finish.
Practical Tips from PythonSkillset.com
When I write guides for PythonSkillset.com, I always include these gotchas:
-
Right-sized workers: Start with
max_workers=10and adjust. Too many workers can overwhelm APIs or storage, too few leaves speed on the table. -
Exception handling: Wrapped in
result()—if a task fails, you’ll catch it when calling.result(). Use try/except around it. -
Context manager: Always use
withto ensure resources are properly cleaned up, even if something crashes. -
Monitor progress: Add a counter inside your parallel loop—surprisingly helpful for long-running tasks.
The Bottom Line
concurrent.futures is one of those Python features that’s both powerful and surprisingly easy to use. For I/O-bound work, it can cut your execution time by 80-90% with just a few lines of code change. The next time you’re processing a list of files or making multiple API calls, give it a try. Your users will thank you for the faster response times.
And if you’re building something at scale, this pattern forms the foundation for more advanced async approaches. But for 99% of real-world I/O tasks, concurrent.futures is all you need.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.