Blog
Concurrency2026-W204 min readby delve

Blocking SQLite in an Async FastAPI Route

A 2ms SQLite read becomes a 200ms tail latency on every other route. The event loop is the bottleneck, not the database. Anything that touches the filesystem or a socket inside an `async def` body belongs in `run_in_executor`.

A single brass funnel on a worn wooden workbench with one droplet caught mid-fall above its narrow neck, sharp focus on the droplet, soft side light, no people, editorial.

The problem

You wire up a new FastAPI endpoint that needs to query a SQLite file — a job queue, feature flags, a local cache. The code looks fine: you open a connection, run fetchone, close it. Benchmarks pass. Then you put it in production and notice that under modest load every other route starts spiking in latency. The SQLite call is 2ms; the tail is 200ms. The database is not the bottleneck — the event loop is.

FastAPI routes are async def and run on the uvicorn event loop. The event loop is a single thread. Any synchronous I/O inside an async def body — file reads, sqlite3.connect, blocking sockets — suspends the entire loop for the duration of that call. One slow SQLite read blocks every concurrent request in the process.

The approach

Python's asyncio provides run_in_executor for exactly this case. It dispatches a callable to a thread pool and returns an awaitable, giving the event loop back while the blocking call runs on a worker thread.

import asyncio import sqlite3 def _query_sync(db_path: str, key: str) -> str | None: if not os.path.exists(db_path): return None with sqlite3.connect(db_path, timeout=5) as con: row = con.execute( "SELECT value FROM cache WHERE key = ? LIMIT 1", (key,), ).fetchone() return row[0] if row else None @app.post("/webhook") async def webhook(request: Request): loop = asyncio.get_running_loop() value = await loop.run_in_executor(None, _query_sync, DB_PATH, key)

The synchronous function runs in the default ThreadPoolExecutor (sized to min(32, cpu_count + 4) on CPython). The event loop is free the entire time. If you need more control over thread count, pass a custom executor as the first argument instead of None.

Two things worth noting: first, get_running_loop() is the correct call inside an async defget_event_loop() is deprecated in 3.10+ and raises a DeprecationWarning in 3.12. Second, the synchronous function must not touch any asyncio primitives; it runs in a thread, not a coroutine, and mixing the two without explicit synchronization is undefined behavior.

What I learned

The tell is latency correlation rather than absolute latency. If a short database call causes long-tail spikes in unrelated routes, the event loop is blocked, not the database. The fix is always the same shape: extract the blocking code into a plain function, hand it to run_in_executor, await the result.

SQLite is the quiet offender here because it looks fast. A 1ms read feels negligible, but at 100 req/s you are blocking the loop for 100ms every second — and that compounds with every other blocking call in the same process. The rule is simple: anything that touches the filesystem or a socket inside an async def body belongs in an executor.