Skip to content

asyncio vs Threads for Database-Heavy Services

For a service that mostly talks to a database, the choice between threads and asyncio is less about the database and more about two limits: how many connections the pool has, and how much client CPU each request costs. Measured on Python 3.14 against PostgreSQL 17, with 200 concurrent clients each running a request of three indexed queries: when every request also spent 10 ms in the database and the pool had 20 connections, threads with psycopg and asyncio with psycopg or asyncpg all landed between 1,734 and 1,823 requests per second — the pool's limit of about 1,818. When queries were fast, the client became the limit: 200 threads with psycopg reached 2,630 req/s using 742 µs of CPU per request, asyncio with psycopg's async API 5,505 at 181 µs, asyncpg 6,429 at 154 µs, and asyncpg on uvloop 12,087 at 81 µs. With a 50-connection pool and 10 ms queries, threads hit the CPU ceiling at 2,435 req/s while asyncio reached 3,634–3,839. This guide runs the comparison and turns it into a decision.

Prerequisites

1. Build a request that looks like a real one

A benchmark of SELECT 1 measures nothing useful. The request here runs three queries on one connection — a primary-key lookup, a small range count, another lookup — against a 100,000-row table, with an optional server-side delay to stand in for slower queries:

def handle_request_sync(pool, i, slow):
    with pool.connection() as c:
        c.execute("SELECT owner, balance FROM acct WHERE id = %s", (i,)).fetchone()
        c.execute("SELECT count(*) FROM acct WHERE id BETWEEN %s AND %s + 50", (i, i)).fetchone()
        if slow:
            c.execute("SELECT pg_sleep(%s)", (slow,))
        c.execute("SELECT owner, balance FROM acct WHERE id = %s", (i + 1,)).fetchone()

async def handle_request_async(pool, i, slow):           # asyncpg
    async with pool.acquire() as c:
        await c.fetchrow("SELECT owner, balance FROM acct WHERE id = $1", i)
        await c.fetchval("SELECT count(*) FROM acct WHERE id BETWEEN $1 AND $1 + 50", i)
        if slow:
            await c.execute("SELECT pg_sleep($1)", slow)
        await c.fetchrow("SELECT owner, balance FROM acct WHERE id = $1", i + 1)

Each configuration ran 200 concurrent clients in a closed loop for 5 seconds, recording latency per request and the client process's CPU time from time.process_time(). The thread version used a ThreadPoolExecutor of 200 threads, so both models had the same concurrency.

Verify: the benchmark records client CPU time alongside throughput; without it, the results cannot say which side is the bottleneck.

2. Measure with slow queries: the pool decides

With a 10 ms pg_sleep in every request and a 20-connection pool, each connection can serve about 90 requests per second, so 20 connections cap throughput near 1,818. Measured:

threads=200, psycopg, pool 20     1,744 req/s   p50 113 ms   client CPU 85%
asyncio, psycopg async, pool 20   1,734 req/s   p50 114 ms   client CPU 54%
asyncio, asyncpg, pool 20         1,763 req/s   p50 112 ms   client CPU 51%
uvloop, asyncpg, pool 20          1,823 req/s   p50 108 ms   client CPU 27%

All four models delivered the same throughput because none of them was the bottleneck: requests queued for a connection, and the median latency of about 110 ms is 200 clients divided by 1,800 per second, as Little's law predicts. Adding threads or tasks beyond the pool size only lengthened that queue. In this regime, the model choice changes only how much CPU is spent waiting — threads used 85% of a core, asyncio with uvloop 27%.

Verify: throughput is close to pool_size / per-request database time; if so, change the pool size or the queries, not the concurrency model.

200 clients, three queries per request, PostgreSQL 17 A grid of 4 rows by 5 columns. 200 clients, three queries per request, PostgreSQL 17 configuration threads + psycopg asyncio + psycopg asyncio + asyncpg uvloop + asyncpg 10 ms queries, pool 20 1,744 1,734 1,763 1,823 fast queries, pool 20 2,630 5,505 6,429 12,087 fast, CPU per request 742 us 181 us 154 us 81 us 10 ms queries, pool 50 2,435 (CPU 200%) 3,634 3,839 not measured Requests per second unless noted. psycopg 3.3.6, asyncpg 0.31.0, uvloop 0.23.0.

3. Measure with fast queries: client CPU decides

Without the delay, each request took well under a millisecond in PostgreSQL, and the client process became the limit. Threads with psycopg reached 2,630 req/s at 195% CPU — psycopg releases the GIL while libpq waits, so more than one core was busy — and spent 742 µs of CPU per request. asyncio with psycopg's async connection pool reached 5,505 req/s at 100% CPU, 181 µs per request: the event loop thread was saturated, but each request cost a quarter of the CPU. asyncpg, with its binary protocol and prepared statement cache, reached 6,429 req/s at 154 µs. Running asyncpg on uvloop halved the CPU per request to 81 µs and reached 12,087 req/s.

The threads' extra cost is the machinery around each query: a GIL handoff every time libpq returns, an OS context switch per wake-up, and 200 threads competing for one interpreter lock. Raising the thread pool's connection count to 50 did not help — 2,788 req/s, still at about 200% CPU.

Verify: if client CPU is at 100% for asyncio or 200% for threads on a GIL build, the client is the bottleneck, and more connections will not raise throughput.

Client CPU per request, fast queries, pool 20 4 horizontal bars comparing threads + psycopg with the others. Client CPU per request, fast queries, pool 20 threads + psycopg 742 us asyncio + psycopg async 181 us asyncio + asyncpg 154 us uvloop + asyncpg 81 us Throughput: 2,630, 5,505, 6,429 and 12,087 req/s. Same three queries per request, 200 concurrent clients.

4. Watch the tail as well as the median

Throughput tables hide fairness. With fast queries and 20 connections, asyncio with psycopg had a p50 of 36 ms and a p99 of 44 ms; asyncpg had a lower p50, 4.0 ms, but a p99 of 160 ms. With 10 ms queries the gap persisted: psycopg's p99 was 133 ms and asyncpg's 318 ms, at the same throughput. Some requests waited much longer than others for a connection from asyncpg's pool in this test. Threads with psycopg had the tightest distribution relative to their median, 113 ms p50 and 124 ms p99.

lat.sort()
p50, p99 = lat[len(lat) // 2], lat[int(len(lat) * 0.99)]
print(f"p50 {p50 * 1000:.1f} ms  p99 {p99 * 1000:.1f} ms  ratio {p99 / p50:.1f}")

If the service has a latency objective at p99, measure the pool you will run, at the concurrency you will run, and compare tails — not just requests per second. Bounding how many requests wait for a connection at all, with a semaphore in front of the pool and fast rejection beyond it, keeps the tail from growing with load; see limiting concurrent requests with asyncio.Semaphore.

Verify: the benchmark reports p99 and the p99/p50 ratio for each driver and pool, and the chosen configuration meets the latency objective.

5. Decide

The measurements give a short decision rule. If the database is the bottleneck — slow queries, a pool sized to what the database can take — threads and asyncio deliver the same throughput, and the existing codebase should decide: a synchronous Django or Flask service gains nothing in throughput from a rewrite. If the client is the bottleneck — many fast queries per request, high request rates — asyncio did the same work with a quarter of the CPU, and asyncpg on uvloop with a ninth.

# Mixed approach: keep a sync codebase, put the hot path on asyncpg
async def hot_endpoint(request):
    async with request.app.state.pg.acquire() as c:
        return await c.fetchrow(LOOKUP, request.path_params["id"])

async def legacy_endpoint(request):
    return await asyncio.to_thread(legacy_orm_call, request.path_params["id"])

A gradual path is possible: async endpoints for the hot queries, and existing synchronous ORM code through to_thread with its own thread-pool limit. Either way, size the pool first: with 50 connections and 10 ms queries, asyncio reached 3,634–3,839 req/s against 2,435 for threads, which had hit 200% CPU — at that point, the model mattered again.

Choosing a model for a database-heavy service A flow of 5 stages. Choosing a model for a database-heavy service Benchmark realistic request, record client CPU Pool-bound? throughput = pool / query time Yes either model, keep the codebase CPU-bound? asyncio cut CPU/request 4-9x Check p99 drivers differed 44 vs 160 ms Identify the limiting resource before choosing.

Verify: the decision names which resource limited throughput in the benchmark — pool or client CPU — and the production pool size matches the tested one.

Verification

The comparison supports a decision when:

  • The request resembles production: several queries per request, real indexes, realistic query time.
  • Client CPU is recorded, so each result can be attributed to the pool or the client.
  • Throughput is compared with pool_size / query time to identify pool-bound runs.
  • Tail latency is compared, since drivers with similar throughput had p99s from 44 ms to 160 ms.

Diagnostic Hook: when a threaded database service stops scaling, compare its CPU with the number of cores it can use. At about 200% on a GIL build with many threads — as measured here with both 20 and 50 connections — the threads are contending for the interpreter, and more threads or connections will not help.

Pitfalls & edge cases

  • Rewriting a pool-bound service for throughput. Measured: 1,734–1,823 req/s for every model.
  • Benchmarking without client CPU. It hides whether the pool or the process is the limit.
  • Comparing drivers on median only. Measured: asyncpg p99 160 ms against 44 ms for psycopg async.
  • More threads than connections. They only queue; 200 threads on 20 connections gave 113 ms p50.

Frequently Asked Questions

Is asyncio faster than threads for database access?

Only when the client is the bottleneck. With 10 ms queries and 20 connections, all models gave about 1,750 req/s; with fast queries, asyncio gave 5,505 against 2,630 for threads.

Why does a threaded database service use so much CPU?

GIL handoffs and OS context switches around every query. Threads with psycopg used 742 µs of CPU per request against 181 µs for asyncio with psycopg.

Is asyncpg faster than psycopg async?

Somewhat: 6,429 against 5,505 req/s and 154 against 181 µs of CPU per request with fast queries. Its p99 was higher in this test, 160 ms against 44 ms.

Does uvloop help database-heavy services?

When the client is the bottleneck, yes: asyncpg on uvloop used 81 µs of CPU per request and reached 12,087 req/s, against 154 µs and 6,429 on the default loop.