← All posts

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

Async Python is not always faster. We ran 6 real benchmarks comparing async vs sync — I/O, CPU, SQLAlchemy, memory, latency — and the results may surprise you.

Henry Hoang7 min read

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

When to use async vs sync in Python

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

I/O-bound benchmark: async 43x faster

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.

CPU-bound benchmark: multiprocessing wins

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

SQLAlchemy throughput: sync wins on localhost

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.

Memory comparison: 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.

MissingGreenletError problem and fixes

# 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.

Latency variance: async p99 much higher

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.