Python Async vs Sync: 6 Benchmarks That Will Change How You Think About Concurrency
"Async makes everything faster" — that's what most developers believe. But is it true? I ran 6 real benchmarks to find out, and the results challenged some of my own assumptions.
Why This Matters
Python's asyncio has become the default recommendation for "high-performance" applications. FastAPI, aiohttp, asyncpg — the async ecosystem is thriving. But blindly adopting async without understanding when it actually helps can lead to:
- More complex code with no performance gain
- Worse tail latency (p99) under load
- MissingGreenletError nightmares in SQLAlchemy
I built 6 runnable benchmarks to prove each point with real numbers. All code is open source on GitHub.
Benchmark 1: I/O-Bound — Async Wins Big
Scenario: 50 HTTP requests, each taking 100ms (simulated with a local mock server).
| Approach | Time | Speedup |
|---|---|---|
| Sync (sequential) | 5.18s | 1x |
| Threading (10 workers) | 0.53s | 9.8x |
| Async (aiohttp) | 0.12s | 43x |
Why? When a sync function waits for I/O, the entire thread blocks. Async suspends the coroutine and lets the event loop handle other tasks while waiting. With 50 concurrent requests, async fires them all simultaneously — total time equals the slowest single request.
Key takeaway: For high-concurrency I/O workloads (web scraping, API calls, WebSocket), async is the clear winner.
Benchmark 2: CPU-Bound — Async Has No Advantage
Scenario: Calculate sum of primes up to 500,000, repeated 8 times.
| Approach | Time | Speedup |
|---|---|---|
| Sync | 6.2s | 1x |
| Async (gather) | 6.2s | 1x (same!) |
| Threading | 7.1s | 0.87x (slower!) |
| Multiprocessing | 1.8s | 3.4x |
Why? Async runs on a single thread. CPU-bound work never yields to the event loop — there's no I/O to wait for. Threading is even worse due to the GIL (Global Interpreter Lock) adding overhead. Only multiprocessing achieves true parallelism by using separate processes with separate GILs.
Key takeaway: For CPU-heavy tasks (ML inference, image processing, data crunching), use multiprocessing or concurrent.futures.ProcessPoolExecutor. Async is pointless here.
Benchmark 3: SQLAlchemy — Sync Beats Async on Localhost
Scenario: Concurrent database queries via SQLAlchemy ORM — sync (psycopg2 + ThreadPoolExecutor) vs async (asyncpg).
| Concurrency | Sync (q/s) | Async (q/s) | Winner |
|---|---|---|---|
| Low (10) | 1,892 | 704 | Sync 2.7x |
| High (100) | 6,287 | 791 | Sync 7.9x |
Surprise! Sync is significantly faster for localhost PostgreSQL. Why?
- Localhost queries are sub-millisecond — there's almost no I/O wait time for async to exploit
- asyncpg + SQLAlchemy ORM overhead — the async driver adds event loop scheduling cost on top of ORM overhead
- ThreadPoolExecutor is well-optimized — OS thread scheduling is very efficient for short-lived tasks
Key takeaway: Async database access shines with remote databases where network latency is significant (5-50ms per query). For localhost or same-datacenter connections, sync is often faster and simpler.
Benchmark 4: Memory — Coroutines Use 107x Less
Scenario: Create 1,000 concurrent tasks — threads vs coroutines.
| Approach | Peak RSS Delta |
|---|---|
| 1,000 Threads | 35.2 MB |
| 1,000 Coroutines | 0.33 MB |
| Ratio | 107x |
Each OS thread requires a stack (typically 1-8 MB). A coroutine is just a Python object — roughly 1 KB. At scale (10,000+ concurrent connections), this difference becomes critical, especially in containerized environments with memory limits.
Key takeaway: If you need thousands of concurrent connections (WebSocket servers, chat systems, IoT gateways), async is the only viable option from a memory perspective.
Benchmark 5: MissingGreenletError — The #1 Async SQLAlchemy Trap
This isn't a performance benchmark — it's a correctness demonstration. The most common error when migrating from sync to async SQLAlchemy.
# SYNC — works fine
author = session.get(Author, 1)
author.books # Lazy loading: implicit SQL query runs automatically
# ASYNC — CRASHES!
author = await session.get(Author, 1)
author.books # MissingGreenletError: implicit I/O forbidden in async
Why? Lazy loading performs an implicit database query when you access a relationship attribute. In async context, all I/O must be explicitly awaited. Python's attribute access (author.books) cannot be awaited.
Three fixes:
| Fix | Code | Best For |
|---|---|---|
selectinload() | .options(selectinload(Author.books)) | Collections (recommended) |
joinedload() | .options(joinedload(Author.books)) | One-to-one relationships |
AsyncAttrs mixin | await author.awaitable_attrs.books | Occasional access |
Key takeaway: Before migrating to async SQLAlchemy, audit every relationship access in your codebase. Set lazy="raise" during development to catch missing eager loads early.
Benchmark 6: Latency Variance — Async p99 Is 29x Higher
Scenario: 100 concurrent queries, measure per-query latency at p50, p95, p99.
| Percentile | Sync | Async | Ratio |
|---|---|---|---|
| p50 (median) | 4.6ms | 1,165ms | 253x |
| p95 | 38.4ms | 1,185ms | 31x |
| p99 | 41.2ms | 1,192ms | 29x |
Why? This is Cal Paterson's finding proven in practice:
- Sync (threads): OS preemptive scheduler distributes CPU time fairly across threads. No single request starves.
- Async (event loop): Cooperative scheduling — if one coroutine takes longer than expected, all other waiting coroutines are delayed. The event loop is single-threaded.
Key takeaway: If your SLA requires low p99 latency (real-time trading, gaming, health monitoring), sync with thread pools may be more predictable than async.
The Decision Framework
Here's my practical guide after running these benchmarks:
| Scenario | Use | Why |
|---|---|---|
| High-concurrency I/O (100+ connections) | Async | 40x faster, 107x less memory |
| CPU-heavy computation | Multiprocessing | 3.4x faster, true parallelism |
| Localhost database | Sync | 8x faster, simpler code |
| Remote database (5ms+ latency) | Async | Overlaps I/O waits |
| Strict p99 requirements | Sync | 29x lower tail latency |
| Simple scripts / CLIs | Sync | KISS — no async complexity |
| WebSocket / real-time | Async | Memory-efficient at scale |
Run It Yourself
All benchmarks are open source and reproducible:
# Clone and setup
git clone https://github.com/henryonai/blog-code.henryonai.com
cd blog-code.henryonai.com
make setup
# Run non-DB demos
cd packages/260325-benchmark-async-vs-sync
make run-all
# Run with PostgreSQL
make docker-up
make test-db
# Generate charts
make charts
Final Thoughts
Async Python is a powerful tool — but it's not a silver bullet. The most important lesson from these benchmarks:
Async excels at waiting efficiently. It doesn't make your code run faster — it makes your code wait smarter.
Before reaching for async/await, ask yourself: "Is my bottleneck I/O wait time, or actual computation?" If it's I/O with high concurrency, async is your friend. For everything else, sync is simpler, more predictable, and often faster.
The Python 3.13 free-threaded mode (no GIL) may change this calculus in the future — but that's a topic for another benchmark.
