Skip to main content

Python Async vs Sync: 6 Benchmarks That Will Change How You Think About Concurrency

· 6 min read
Henry Hoang
AI Engineer

"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).

ApproachTimeSpeedup
Sync (sequential)5.18s1x
Threading (10 workers)0.53s9.8x
Async (aiohttp)0.12s43x

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.

ApproachTimeSpeedup
Sync6.2s1x
Async (gather)6.2s1x (same!)
Threading7.1s0.87x (slower!)
Multiprocessing1.8s3.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).

ConcurrencySync (q/s)Async (q/s)Winner
Low (10)1,892704Sync 2.7x
High (100)6,287791Sync 7.9x

Surprise! Sync is significantly faster for localhost PostgreSQL. Why?

  1. Localhost queries are sub-millisecond — there's almost no I/O wait time for async to exploit
  2. asyncpg + SQLAlchemy ORM overhead — the async driver adds event loop scheduling cost on top of ORM overhead
  3. 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.

ApproachPeak RSS Delta
1,000 Threads35.2 MB
1,000 Coroutines0.33 MB
Ratio107x

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:

FixCodeBest For
selectinload().options(selectinload(Author.books))Collections (recommended)
joinedload().options(joinedload(Author.books))One-to-one relationships
AsyncAttrs mixinawait author.awaitable_attrs.booksOccasional 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.

PercentileSyncAsyncRatio
p50 (median)4.6ms1,165ms253x
p9538.4ms1,185ms31x
p9941.2ms1,192ms29x

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:

ScenarioUseWhy
High-concurrency I/O (100+ connections)Async40x faster, 107x less memory
CPU-heavy computationMultiprocessing3.4x faster, true parallelism
Localhost databaseSync8x faster, simpler code
Remote database (5ms+ latency)AsyncOverlaps I/O waits
Strict p99 requirementsSync29x lower tail latency
Simple scripts / CLIsSyncKISS — no async complexity
WebSocket / real-timeAsyncMemory-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.