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

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 def — get_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.
