Concurrency & Async
How SQLite concurrency works
Each connection has a lock (mutex to protect the SQLite data structure. It is acquired on a call into the connection, and released on return of the call. The mutex can be acquired more times in the same thread, allowing nested calls, but cannot be acquired outside of the thread until the top level call in the original thread completes.
This means you cannot get more concurrency per connection by using additional threads, although SQLite can do so internally (pragma threads) such as for sorting. You can get concurrency with multiple connections.
SQLite is inherently synchronous due to being written in C and using the C stack.
How Python concurrency works
GIL (usual operation)
A single lock (global interpreter lock) protects all Python data structures. It can only be held by one thread at a time. By default it will switch the active thread every 5ms (200 times a second), with the operating system scheduler choosing which thread runs next.
The GIL can be released by C code when not using Python data structures to allow other threads Python to run. This is done during I/O and database operations etc.
Free threaded (Python 3.14+)
Each Python data structure gets its own lock. C code has to acquire and release the locks on individual Python objects being used, which allows code in other threads to run providing they are not using the same objects.
The extra locking can result in around a 50% performance hit versus the single global lock in a single thread. The operating system scheduler can run all the threads at the same time.
Async
async is a language level concurrency mechanism, contrasted with the traditional library mechanism in
concurrent.futures. It is done with theasyncandawaitkeywords.An event loop does fine grained management of multiple tasks as their results become available, timeouts, cancellations, task groups etc, with the tasks cooperatively defining the points at which they can switch. There is typically a 50% hit to throughput, but latencies and time to complete are far more uniform.
An async framework like
asyncioor trio runs the event loop and chooses which code to run next.
How APSW works
GIL (usual operation)
The GIL protects APSW Python objects. When making a SQLite call, the GIL is released and SQLite acquires its connection mutex. This means other Python code can run concurrently with SQLite connections. APSW is thread safe in that you can use any thread to make any call on any object, due to the GIL protection.
Free threaded
APSW has not yet been updated. (APSW issue 568) It requires extensive code changes to lock all APSW objects (connections, cursors, backups etc) as well as inputs and outputs such as lists for executemany.
A free threaded build is available on pypi, but loading the module will re-enable GIL. You can use
-X nogilwhen starting Python and that won’t happen, but you can get crashes if you use the same objects and inputs concurrently across threads, especially if making concurrent modifications.
Async
APSW fully supports async operation. This is done by running each connection in its own dedicated worker thread. Calls made in the event loop get forwarded to the worker thread, and the results can then be awaited.
All callbacks such as user defined functions, virtual tables, various hooks, VFS etc can be async functions. The async function (coroutine) is forwarded from the worker thread back to the event loop with the worker thread blocked until getting a result back.
Async usage
APSW async usage has been developed and tested with asyncio,
trio (note), and anyio (note) with asyncio and trio event loops.
This includes cancellations and deadlines/timeouts. There is a
controller interface (described below) providing event loop
integration, or you can write/adapt your own if you have more
specialised needs, or a different async framework.
Use Connection.as_async() (a class method) to get an async
Connection. Related objects like Cursor,
Blob, Backup, Session etc will also be
async.
db = await apsw.Connection.as_async("database.db")
# note awaiting the db.execute call to get the cursor, and then
# using async for to iterate
async for name, price in await db.execute("SELECT name, price FROM ..."):
print(f"{name=) {price=}")"
There is no separate AsyncConnection (or AsyncCursor,
AsyncBlob etc) class. The existing instances know if they are in
async mode or not, and behave appropriately. You can use
Connection.is_async to check.
However to make type checkers and IDEs work better, the type stubs included with APSW have those synthetic classes so it is clear when returned values are direct, or need to be awaited.
You can use Connection.async_run() to run your own functions in
the async Connection worker thread.
AnyIO note
Version 4.11.0 (September 2025) or later is required for the APSW
provided controller due to the mechanisms
for calling back from the worker thread to the event loop. If you are
using an earlier version of anyio, then APSW will use the corresponding
event loop controller (AsyncIO or
Trio). The main effect is that you may see
trio.TooSlowError on timeouts with a trio event loop.
The APSW tests run using version 4 of the anyio API.
Trio note
Version 0.20.0 (February 2022) or later is required for the APSW
provided controller due to the mechanisms
for supporting contextvars. You will need a custom controller
to work with earlier versions.
Attributes
Some SQLite functions are provided in APSW as attributes such as
Connection.authorizer. For an async connection, you will need
to await the result.
auth = await connection.authorizer
To set them, you will need to use setattr in the worker thread.
await connection.async_run(setattr, connection, "authorizer", my_auth)
The type stubs will make this clear to your IDE and type checker.
API results
Each API has an indicator of its behaviour in sync and async modes.
You can find out if an object is in async mode by checking its
corresponding Connection.is_async.
The included type stubs will show correct usage for your IDE and type checker.
Sync only
- Sync object
A direct result will be provided
- Async object
A
TypeErrorwill be raised. There may be an async equivalent such asacloseinstead ofclose, or you may needasync withinstead ofwith,async forinstead offor
Async only
- Sync object
A
TypeErrorwill be raised. Omit the leasingasyncand use plainwithandfor. There may be a sync equivalent without a leadinga.- Async object
You will need to
awaitthe result when ready
Sync / Async
- Sync object
A direct result will be provided
- Async object
You will need to
awaitthe result when ready. When objects are returned likeCursor,Blob,Backupetc, they will also be in async mode.`
Value
- Sync object
A direct result will be provided
- Async object
A direct result will be provided. Do not
awaitit.
Close
You can call close on sync and async objects. When you call close on a
Connection, then it will close all the corresponding
objects like Cursor, Blob, Session etc.
It is safe to call close and aclose multiple times,
even on already closed objects.`
- Sync object
closecloses this object in the foreground, releasing its held resources.- Async object
aclosecloses this object in the background. You shouldawaitcallingacloseto know when it has completed.closewill close it in the foreground which could take some time.
Callbacks
SQLite has numerous hooks and callbacks such as functions, hooks, virtual tables, and
VFS.
If you provide sync versions they get called in the connection worker thread. You can also provide an async callback/method. The async controller will suspend execution in the worker thread, send the callback back to the event loop, and resume execution on getting a result (or exception).
Warning
DEADLOCK
If your async callback makes a request back into the connection and awaits it, then you will get a deadlock. The connection cannot proceed until it gets a result, and the callback is waiting on the suspended connection.
Contextvars
contextvars let you provide thread local and async context
specific values. It saves having to provide a parameter to every
function in a call chain, instead letting those that care reach out
and find the current value for their context.
contextvar values at the point of a query in the event loop are propagated to their processing in the database worker thread, being available to any callbacks, and are also propagated back to the event loop if any callbacks are async.
Warning
Not a regular variable
Each time you set() a value, the
previous value is saved behind the scenes so it can be
restored. You will get
ever increasing memory consumption if you do not
reset().
apsw.aio.contextvar_set() shows a convenient way of doing
so.
The context is copied (a trivial internal operation) as it passes from async to worker thread and back to async again. Setting a variable is not visible to code earlier in the call chain. Use a dict or similar as the value set by the initial code to provide somewhere all the code in the chain can see and make changes.
Configuration
Most configuration uses contextvars.
How many rows are fetched at once when iterating query results.
How frequently running SQLite queries check for cancellations and timeouts
Interface between async framework and worker thread.
How the worker thread runs an async callback back in the event loop.
Deadlines and Cancellation
The native cancellation of each framework is supported. This is often used to cancel all tasks in a group if one fails, and to support timeouts/deadlines.
An example usage of deadlines is if you use a function or virtual table that makes network requests. When executing a query you can ensure reasonable bounds for how long it takes, bounding the internal functions and virtual tables used to answer the query.
You can set a deadline by which an API request must timeout if not completed. This includes sync and async callbacks that are made to satisfy the request. The deadline is captured at the point the call is made, and subsequent changes are not observed.
You can use apsw.aio.deadline to set the deadline - its
documentation provides more details.
Trio and anyio have timeout managers. If apsw.aio.deadline is
not set, then their current_effective_deadline used.
Async controllers
A controller configured via apsw.async_controller is used to
integrate with the async framework. apsw.aio contains
implementations for asyncio, trio (note), anyio (note), and
auto-detection (the default).
The controller is responsible for:
Starting the worker thread
Configuring the connection in the worker thread
Sending calls from the event loop to the worker thread with awaitable results
Checking deadlines and cancellations
Forwarding coroutines in the event loop, and providing their results
Stopping the worker thread when told about database close
Although it seems like a lot, they are around 50 lines of code, and
conform to the AsyncConnectionController protocol.
Run in thread (alternative)
Instead of using APSW in async mode, you can request your framework
run expensive operations in a thread. For example
asyncio.to_thread(), trio.to_thread.run_sync() and
anyio.to_thread.run_sync() can do that for you.
Extensions
Session
You should use
apsw.aio.make_session()to make aSessionin async mode.
Virtually every method and property needs to access the database. Therefore you should run all of them in the database thread using
Connection.async_run()db = await apsw.Connection.as_async("my.db") # creating a table table = await db.async_run( # table creation method apsw.fts5.Table.create, # the various parameters db, "search", content="recipes", columns=... ) # loading existing table table = await db.async_run( # load method apsw.fts5.Table, # parameters db, "search" ) # row count (attribute) row_count = await db.async_run( getattr, table, "row_count" ) # query suggestions (method) suggest = await db.async_run( table.query_suggest, query )
apsw.aio module
Implements async framework controllers, configuration, and helpers
- apsw.aio.deadline: ContextVar = <ContextVar name='apsw.aio.deadline' default=None>
Absolute time deadline for a request in seconds
This makes a best effort to timeout a database operation including any sync and async callbacks if the deadline is passed. The default (
None) is no deadline.The deadline is set at the point an APSW call is made, and changes after that are not observed. It is based on the clock used by the event loop. Typical usage is:
# 10 seconds from now. You'll need to get the time from your # framework as documented below. with apsw.aio.contextvar_set(apsw.aio.deadline, anyio.current_time() + 10): async for row in await db.execute("time consuming query ..."): print(f"{row=}")
apsw.aio.deadlineis the only way to set a deadline.TimeoutErrorwill be raised if the deadline is exceeded. The current time is available fromasyncio.get_running_loop().time()If
apsw.aio.deadlineis set then it is used for the deadline.trio.TooSlowErroris raised. The current time is available fromtrio.current_time().Otherwise the
trio.current_effective_deadline()where the call is made is used.AnyIO
If
apsw.aio.deadlineis set then it is used for the deadline.TimeoutErroris raised. The current time is available fromanyio.current_time().Otherwise the
anyio.current_effective_deadline()where the call is made is used.
- apsw.aio.check_progress_steps: ContextVar = <ContextVar name='apsw.aio.check_progress_steps' default=50000>
How many internal SQLite steps between checks for cancellation and deadlines
While SQLite queries are executing, periodic checks are made to see if the request has been cancelled, or the deadline exceeded. This is done in the
progress handler.The default should correspond to around 10 checks per second, but will vary a lot based on the queries. The smaller the number, the more frequent the checks, but also more time consumed making the checks.
This is only used during connection creation. Typical usage is:
with apsw.aio.contextvar_set(apsw.aio.check_progress_steps, 500): db = await apsw.Connection.as_async(...)
- apsw.aio.contextvar_set(var: ContextVar, value: T) Token[source]
Wrapper for setting a
ContextVarduring awithblockPython 3.14+ lets you do:
with var.set(value): # code here ...
This wrapper provides the same functionality for all Python versions:
with apsw.aio.contextvar_set(var, value): # code here ...
- async apsw.aio.make_session(db: AsyncConnection, schema: str) AsyncSession[source]
Helper to create a
Sessionin async mode for an async database
- class apsw.aio.AsyncIO(*, thread_name: str = 'asyncio apsw background worker')[source]
Controllerforasyncio- configure(db: Connection)[source]
Setup database, just after it is created
- set_future_exception(future: Future, exc: BaseException)[source]
- async_run_coro(coro: Coroutine)[source]
Called in worker thread to run a coroutine in the event loop
- class apsw.aio.Trio(*, thread_name: str = 'trio apsw background worker')[source]
Controllerfor trio (note)- configure(db: Connection)[source]
Setup database, just after it is created
- async_run_coro(coro: Coroutine)[source]
Called in worker thread to run a coroutine in the event loop
- class apsw.aio.AnyIO(*, thread_name: str = 'anyio apsw background worker')[source]
Controllerfor anyio (note)- configure(db: Connection)[source]
Setup database, just after it is created
- async_run_coro(coro: Coroutine)[source]
Called in worker thread to run a coroutine in the event loop
- apsw.aio.Auto() Trio | AsyncIO | AnyIO[source]
Automatically detects the current async framework running event loop and returns the appropriate controller. This is the default for
apsw.async_controller.AnyIO note
The
AnyIOcontroller is only returned ifanyio.run()is in the call stack.If you are simultaneously using anyio and another framework then you should manually configure
apsw.async_controllerto get the one you want.This matters especially for timeouts and cancellations where each framework is different.
RuntimeErroris raised if the framework can’t be detected.
Async Performance
Performance is dominated by the overhead of sending calls to the worker thread, and getting the result. tools/aio_bench.py is a small benchmark that keeps reading rows from a dummy memory database, and then appending 1,000 more rows to the end of the table, until there are 300,000 rows in the table.
Benchmarks aren’t real - use your own scenario for real testing!
Library
apsw with
asyncio, asyncio using uvloop as the inner loop, trio (note), and anyio (note) with asyncio and trio event loops.The aiosqlite library (asyncio only) is included for comparison which also sends calls to a worker thread. Note that it doesn’t support cancellation, timeouts, or async callbacks.
Prefetch
How many rows are fetched in a batch for queries, controlled by
apsw.async_cursor_prefetchin APSW anditer_chunk_sizein aiosqlite. A value of 1 as shown in the first rows ends up as 301 thousand messages and responses with the worker thread. That is halved with 2 etc. The default is 64. The benchmark queries return a maximum of 1,000 rows.
Wall
Wall clock time in seconds for the configuration to run.
CpuTotal / CpuEvtLoop / CpuDbWorker
The total CPU time used in seconds, with how much of that was in the async event loop thread, and how much in the background database worker thread.
The results show that what is used only matters if you are doing very large numbers of calls because of very small row batch sizes.
Library |
Prefetch |
Wall |
CpuTotal |
CpuEvtLoop |
CpuDbWorker |
|---|---|---|---|---|---|
apsw AsyncIO |
1 |
7.721 |
8.010 |
3.168 |
4.841 |
apsw AsyncIO uvloop |
1 |
5.064 |
5.256 |
1.521 |
3.735 |
apsw Trio |
1 |
12.029 |
13.308 |
7.805 |
5.504 |
apsw AnyIO asyncio |
1 |
15.202 |
18.158 |
7.660 |
10.499 |
apsw AnyIO asyncio uvloop |
1 |
9.652 |
11.183 |
3.766 |
7.416 |
apsw AnyIO trio |
1 |
16.670 |
19.597 |
10.028 |
9.570 |
aiosqlite |
1 |
8.360 |
8.755 |
3.909 |
4.846 |
aiosqlite uvloop |
1 |
5.358 |
5.635 |
1.867 |
3.769 |
apsw AsyncIO |
2 |
4.287 |
4.441 |
1.684 |
2.758 |
apsw AsyncIO uvloop |
2 |
2.895 |
2.987 |
0.797 |
2.190 |
apsw Trio |
2 |
6.403 |
7.022 |
3.936 |
3.086 |
apsw AnyIO asyncio |
2 |
8.175 |
9.702 |
3.919 |
5.783 |
apsw AnyIO asyncio uvloop |
2 |
4.966 |
5.704 |
1.810 |
3.894 |
apsw AnyIO trio |
2 |
8.683 |
10.140 |
5.081 |
5.059 |
aiosqlite |
2 |
4.885 |
5.106 |
2.180 |
2.926 |
aiosqlite uvloop |
2 |
3.288 |
3.454 |
1.129 |
2.326 |
apsw AsyncIO |
16 |
1.231 |
1.271 |
0.324 |
0.947 |
apsw AsyncIO uvloop |
16 |
0.977 |
0.993 |
0.162 |
0.831 |
apsw Trio |
16 |
1.546 |
1.667 |
0.631 |
1.036 |
apsw AnyIO asyncio |
16 |
1.671 |
1.877 |
0.570 |
1.306 |
apsw AnyIO asyncio uvloop |
16 |
1.255 |
1.359 |
0.286 |
1.074 |
apsw AnyIO trio |
16 |
1.776 |
1.990 |
0.738 |
1.253 |
aiosqlite |
16 |
1.152 |
1.191 |
0.382 |
0.809 |
aiosqlite uvloop |
16 |
0.868 |
0.888 |
0.191 |
0.697 |
apsw AsyncIO |
64 |
0.847 |
0.868 |
0.145 |
0.723 |
apsw AsyncIO uvloop |
64 |
0.740 |
0.744 |
0.072 |
0.671 |
apsw Trio |
64 |
0.883 |
0.922 |
0.207 |
0.715 |
apsw AnyIO asyncio |
64 |
0.984 |
1.044 |
0.212 |
0.832 |
apsw AnyIO asyncio uvloop |
64 |
0.835 |
0.865 |
0.124 |
0.741 |
apsw AnyIO trio |
64 |
0.983 |
1.056 |
0.249 |
0.807 |
aiosqlite |
64 |
0.718 |
0.729 |
0.153 |
0.575 |
aiosqlite uvloop |
64 |
0.619 |
0.623 |
0.102 |
0.521 |
apsw AsyncIO |
512 |
0.702 |
0.715 |
0.071 |
0.644 |
apsw AsyncIO uvloop |
512 |
0.668 |
0.668 |
0.053 |
0.615 |
apsw Trio |
512 |
0.706 |
0.724 |
0.101 |
0.623 |
apsw AnyIO asyncio |
512 |
0.746 |
0.767 |
0.090 |
0.676 |
apsw AnyIO asyncio uvloop |
512 |
0.694 |
0.703 |
0.068 |
0.636 |
apsw AnyIO trio |
512 |
0.736 |
0.762 |
0.105 |
0.657 |
aiosqlite |
512 |
0.583 |
0.586 |
0.098 |
0.488 |
aiosqlite uvloop |
512 |
0.561 |
0.563 |
0.084 |
0.479 |
apsw AsyncIO |
8,192 |
0.670 |
0.681 |
0.065 |
0.616 |
apsw AsyncIO uvloop |
8,192 |
0.652 |
0.653 |
0.042 |
0.610 |
apsw Trio |
8,192 |
0.703 |
0.720 |
0.089 |
0.630 |
apsw AnyIO asyncio |
8,192 |
0.711 |
0.724 |
0.078 |
0.646 |
apsw AnyIO asyncio uvloop |
8,192 |
0.697 |
0.707 |
0.064 |
0.643 |
apsw AnyIO trio |
8,192 |
0.733 |
0.761 |
0.098 |
0.663 |
aiosqlite |
8,192 |
0.564 |
0.566 |
0.091 |
0.475 |
aiosqlite uvloop |
8,192 |
0.542 |
0.543 |
0.076 |
0.467 |
apsw AsyncIO |
65.536 |
0.676 |
0.686 |
0.066 |
0.621 |
apsw AsyncIO uvloop |
65.536 |
0.658 |
0.659 |
0.051 |
0.608 |
apsw Trio |
65.536 |
0.700 |
0.713 |
0.093 |
0.620 |
apsw AnyIO asyncio |
65.536 |
0.724 |
0.738 |
0.090 |
0.648 |
apsw AnyIO asyncio uvloop |
65.536 |
0.698 |
0.706 |
0.072 |
0.634 |
apsw AnyIO trio |
65.536 |
0.743 |
0.769 |
0.115 |
0.654 |
aiosqlite |
65.536 |
0.560 |
0.562 |
0.092 |
0.471 |
aiosqlite uvloop |
65.536 |
0.542 |
0.543 |
0.073 |
0.470 |