Making Synchronous DB-API Work in Async Python

1. The Problem: DB-API is Synchronous

The Python database ecosystem is built on PEP 249, which defines a common interface for database drivers.

However, DB-API was designed in a synchronous world.

Typical usage looks like this:

conn = connect(...)
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
rows = cursor.fetchall()

Every call here is blocking. If the database takes time to respond, the calling thread is blocked until the result is returned.

2. Why This Matters in Async Applications

Modern Python applications often use async frameworks (e.g., FastAPI, asyncio-based systems).

These rely on an event loop, where:

  • One thread handles many concurrent tasks
  • Tasks must yield control when waiting

If a blocking DB call is executed:

cursor.execute(...)

The entire event loop is blocked and other requests cannot progress. This defeats the purpose of async concurrency.

3. Two Approaches to “Async” Database Access

There are two main ways to integrate synchronous DB-API into async applications.

3.1 Thread Offloading

One approach is to run blocking operations in a separate thread:

await asyncio.to_thread(sync_execute)

How it works:

  • The blocking DB call is moved to a worker thread
  • The main event loop remains responsive
  • A thread pool manages concurrent executions

Characteristics:

  • The DB operation is still fully synchronous
  • Threads are reused, but concurrency is limited by pool size
  • There is overhead from thread scheduling and context switching

This approach does not change the I/O model It simply hides blocking by moving it elsewhere

3.2 Non-blocking I/O (Async Drivers)

The second approach is to use drivers that support non-blocking I/O, such as asyncpg.

await conn.execute(...)

How it works:

  • Database communication uses non-blocking sockets
  • The event loop monitors I/O readiness
  • Execution resumes only when data is available

Characteristics:

  • No threads are required for I/O waiting
  • High concurrency with minimal overhead
  • Scales efficiently under load

Here, the I/O itself is truly asynchronous.

4. Key Difference and Database Nuance

The fundamental distinction is:

Thread offloading moves blocking work elsewhere, while non-blocking I/O eliminates blocking altogether.

Async behavior is not only determined by the Python driver — it also depends on the database architecture.

| Approach | Core difference | Typical fit | Pros | Cons | | --- | --- | --- | --- | --- | | Thread offloading | Runs blocking DB calls in worker threads | Embedded/file-based DBs like SQLite (aiosqlite) | Easy compatibility with synchronous drivers, keeps event loop responsive | Still blocking at the DB call level, thread/context-switch overhead, limited by thread pool size | | Non-blocking I/O | Uses event-loop-driven socket I/O | Server DBs over TCP like PostgreSQL (asyncpg) | True async waiting, high concurrency, lower overhead under load | Requires database/driver support, not universally available |

In practice:

  • SQLite (local file I/O) usually relies on thread offloading
  • PostgreSQL (TCP socket I/O) can use true non-blocking I/O

5. How Libraries Handle This

Libraries like SQLAlchemy provide a unified async interface.

Internally, they:

  • Use async drivers when available
  • Fall back to thread offloading when necessary

This allows developers to write async code without worrying about backend differences.

See about sqlite async implementation here; https://github.com/sqlalchemy/sqlalchemy/blob/main/lib/sqlalchemy/dialects/sqlite/aiosqlite.py

6. Conclusion

DB-API remains synchronous by design, but modern applications require asynchronous behavior.

There are two ways to bridge this gap:

  • Thread offloading (practical, but limited)
  • Non-blocking I/O (scalable, but driver-dependent)

The key insight is:

Async is not about wrapping synchronous code — it is about how I/O is performed.

Understanding this distinction is essential for building efficient, scalable Python applications.