
Enhances SQLDelight with a driver wrapping AndroidX SQLite libraries, simplifying database management via the AndroidxSqliteDriver factory, handling migrations seamlessly.
The SQLDelight AndroidX Driver provides a SQLDelight driver that wraps AndroidX Kotlin Multiplatform SQLite libraries. It works with any of the available implementations of AndroidX SQLite.
[!NOTE]
If you are migrating from version 0.0.17 or earlier, check out the migration guide
First setup SQLite dependencies following Set up SQLite for KMP.
Next add the dependency to your project.
repositories {
mavenCentral()
}
// Android/JVM
dependencies {
implementation("com.eygraber:sqldelight-androidx-driver:0.5.0-alpha.11")
}
// Multiplatform
commonMain.dependencies {
implementation("com.eygraber:sqldelight-androidx-driver:0.5.0-alpha.11")
}See Consuming Via Gradle for how to add a -SNAPSHOT release.
Next configure the database.
[!IMPORTANT]
generateAsyncmust equaltruesinceAndroidxSqliteDriveris a suspending driver.
[!IMPORTANT] If you're producing a dynamic framework for a Kotlin/Native target (typically iOS) and using
BundledSQLiteDriver(), setlinkSqlite = false. SQLDelight defaults to passing-lsqlite3to the linker so the framework links against the system SQLite, butandroidx.sqlite:sqlite-bundledalready statically bundles its own SQLite — leavinglinkSqlite = truemakeslldfail withlibrary not found for -lsqlite3.
sqldelight {
linkSqlite = false
databases {
register("Database") {
generateAsync = true
}
}
}[!TIP] AndroidX team recommends
BundledSQLiteDriverbut you can use whichever one suits your needs. See SQLite Driver Implementations for more info.
Android or JVM - Pass a File:
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File(File("my.db")),
schema = Database.Schema,
)
val database = Database(driver)
// More database stuff...Android - Use Context to create the file in the app's database directory:
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "my.db"),
schema = Database.Schema,
)
val database = Database(driver)
// More database stuff...Multiplatform - Create a database type factory:
// src/commonMain/kotlin
expect class DatabaseTypeFactory {
fun createDatabaseType(): AndroidxSqliteDatabaseType
}
fun createDatabase(databaseTypeFactory: DatabaseTypeFactory): Database {
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = databaseTypeFactory.createDatabaseType(),
schema = Database.Schema
)
val database = Database(driver)
// More database stuff...
}
// src/androidMain/kotlin
actual class DatabaseTypeFactory(private val context: Context) {
actual fun createDatabaseType(): AndroidxSqliteDatabaseType {
return AndroidxSqliteDatabaseType.FileProvider(context, "my.db")
}
}
// src/nativeMain/kotlin
actual class DatabaseTypeFactory {
actual fun createDatabaseType(): AndroidxSqliteDatabaseType {
return AndroidxSqliteDatabaseType.File("<absolute path to db file>")
}
}
// src/jvmMain/kotlin
actual class DatabaseTypeFactory {
actual fun createDatabaseType(): AndroidxSqliteDatabaseType {
AndroidxSqliteDatabaseType.File(File("my.db"))
}
}Add the :opfs-driver dependency to your web source set:
val webMain by getting {
dependencies {
implementation("com.eygraber:sqldelight-androidx-driver-opfs:0.5.0-alpha.11")
}
}On web, the SQLiteDriver constructor differs from non-web targets, so make createSqliteDriver
an expect/actual. The databaseType factory from the previous section stays the same:
// src/commonMain/kotlin
expect fun createSqliteDriver(): SQLiteDriver
fun createDatabase(databaseTypeFactory: DatabaseTypeFactory): Database {
val driver = AndroidxSqliteDriver(
driver = createSqliteDriver(),
databaseType = databaseTypeFactory.createDatabaseType(),
schema = Database.Schema,
)
return Database(driver)
}
// src/nonWebMain/kotlin (or separately in androidMain / jvmMain / nativeMain)
actual fun createSqliteDriver(): SQLiteDriver = BundledSQLiteDriver()
// src/webMain/kotlin (or separately in jsMain / wasmJsMain)
actual fun createSqliteDriver(): SQLiteDriver = androidxSqliteOpfsDriver()On web, build the databaseType with AndroidxSqliteDatabaseType.File("my.db") — the file is
persisted in the browser's Origin Private File System.
[!IMPORTANT] On web the file name is a flat key in the OPFS pool. The pool accepts a name like
"data/my.db"as one key and creates no directory. A name over 511 bytes fails to open.
[!NOTE] If your project only targets web, you don't need
expect/actual— just callandroidxSqliteOpfsDriver()and pass it toAndroidxSqliteDriver.
CI runs the test suites on both web targets. The js target works with the default Kotlin/JS
module kind, so useEsModules() is not required. The driver resolves the @sqlite.org/sqlite-wasm
files with new URL(..., import.meta.url). The Kotlin/JS webpack build rewrites these URLs into
bundled asset URLs.
androidxSqliteOpfsDriver() returns an OpfsSqliteDriver. It owns a Web Worker that holds the
OPFS handles, the multi-tab Web Locks, and the cross-tab channels. AndroidxSqliteDriver.close()
closes the connection and then closes the OpfsSqliteDriver, which releases all of these and
terminates the worker. Call it when you recreate the database, for example on logout.
val driver = AndroidxSqliteDriver(
driver = androidxSqliteOpfsDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
)
// later
driver.close()close() is idempotent. After it, open on the OpfsSqliteDriver fails with an
IllegalStateException, and queries that the worker had queued fail with an exception.
See Closing the driver for what happens to statements in progress.
Pass an OpfsMultiTabMode to androidxSqliteOpfsDriver(...) to pick how tabs coordinate access:
PauseOnHidden (default) — the active tab uses the database; backgrounded tabs pause and
queue their queries until they regain focus. Adds no per-query overhead. Use this when your
app is primarily used in one tab at a time, or if it is OK for queries to queue when your tab is not active.
If a tab is hidden while a transaction is open, the hand-off waits until that transaction ends.
The other tab gets the database after the commit or the rollback.Shared — every tab can run queries concurrently regardless of focus. One tab is elected
the database owner and serves queries on behalf of the others; ownership transfers
automatically when the owning tab closes. Use this when users may actively interact with
multiple tabs simultaneously, and you're willing to pay a cross-tab round-trip per query in
non-owner tabs.Single — only one tab can use the database at a time; opening a second tab while the
first is open will fail. Use this only when your app is guaranteed to run in a single tab.import com.eygraber.sqldelight.androidx.driver.opfs.OpfsMultiTabMode
import com.eygraber.sqldelight.androidx.driver.opfs.androidxSqliteOpfsDriver
val driver = androidxSqliteOpfsDriver(multiTabMode = OpfsMultiTabMode.Shared)In PauseOnHidden, queries from a backgrounded tab queue silently. Pass onLockStateChange to
render an indicator instead:
import com.eygraber.sqldelight.androidx.driver.opfs.OpfsLockState
import com.eygraber.sqldelight.androidx.driver.opfs.androidxSqliteOpfsDriver
val lockState = MutableStateFlow(OpfsLockState.Live)
val driver = androidxSqliteOpfsDriver(
onLockStateChange = { lockState.value = it },
)The callback fires once synchronously with the initial state, and again on each transition. In
Single and Shared modes it fires exactly once with Live. It does not fire after the driver
is closed.
For background on how the web driver is built and how to substitute your own worker if
:opfs-driver doesn't fit your needs, see Web driver design notes.
If you want to provide OpenFlags to the bundled or native driver, you can use:
Database(
AndroidxSqliteDriver(
connectionFactory = object : AndroidxSqliteConnectionFactory {
override val driver = BundledSQLiteDriver()
override suspend fun createConnection(name: String) =
driver.open(name, SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE)
},
databaseType = AndroidxSqliteDatabaseType.File("<absolute path to db file>"),
schema = Database.Schema,
)
)It will handle calling the create and migrate functions on your schema for you, and keep track of the database's version.
AndroidxSqliteDriver is a suspending driver, so SQLDelight must be configured with
generateAsync = true:
sqldelight {
databases {
register("Database") {
generateAsync = true
}
}
}With generateAsync = true, generated queries return QueryResult.AsyncValue rather than
materialized values — you must call .await() (or the awaitAsOne/awaitAsList/etc. helpers
from app.cash.sqldelight:async-extensions) to get the result:
val user: User = database.userQueries.selectById(id).awaitAsOne()
val users: List<User> = database.userQueries.selectAll().awaitAsList()All database calls are suspending, and the driver runs them on its own coroutine dispatchers —
you don't need to wrap calls in withContext(Dispatchers.IO). Learn more below.
AndroidxSqliteDriver accepts four optional callbacks, all invoked at most once on the first
interaction with the database:
AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
onConfigure = {
// Suspending. Runs before create/migrate. Use it to override pragmas that aren't
// covered by AndroidxSqliteConfiguration.
setForeignKeyConstraintsEnabled(true)
},
onCreate = { /* side effects after first create */ },
onUpdate = { old, new -> /* side effects after migration */ },
onOpen = { /* side effects after create or migrate */ },
)All four callbacks run at most once per driver instance, on the first database interaction,
guarded by an internal mutex — concurrent first-time callers all wait for them to complete before
any queries execute. If you close the driver and construct a new one against the same database,
onConfigure and onOpen run again for the new instance; onCreate and onUpdate only run if
the schema actually needs to be created or migrated.
onConfigure runs first, before any schema work. It's the right place to override pragmas
that aren't covered by AndroidxSqliteConfiguration.onCreate runs only when the database is created for the first time, after SqlSchema.create
has committed.onUpdate runs only when the schema version has increased, after SqlSchema.migrate has
committed.onOpen runs on every first interaction, after any create/migrate work.All four callbacks are suspend lambdas, so you can await() driver operations directly inside
them. Note that onCreate, onUpdate, and onOpen run after the create/migrate transaction
has committed — they aren't part of it. To seed data or run additional SQL inside the
create/migrate transaction, put it in your SqlSchema.create / SqlSchema.migrate, or use
migrationCallbacks (see below).
close() never blocks and never throws. Idle connections close immediately. A connection in use
closes when its coroutine releases it. New statements fail with an IllegalStateException.
On the JVM, Android, and native, a statement or transaction in progress completes, even when
close() runs inside the transaction. The dispatcher shuts down after the last connection closes.
On web, close() closes the OpfsSqliteDriver and its worker immediately. A paused driver can
hold a statement for as long as the tab is hidden, so the worker does not wait for it. A statement
that the worker has queued fails with an exception. The statement that the worker executes at that
moment completes.
awaitClosed() suspends until the last connection closes:
driver.close()
driver.awaitClosed()Call it before you open the same file with a new driver. Call it before you change the database files yourself.
The driver opens the database on the first query. initialize() does that work immediately and
throws what a first query would throw. Use it to catch a failed migration or a corrupt file at a
known point.
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
)
try {
driver.initialize()
} catch(e: SQLiteException) {
// decide what to do with the file
}A second call does nothing.
recreateSchema() drops every schema object, then runs SqlSchema.create and onCreate. The
dropped objects are tables, views, triggers, and indexes. All data is lost. The work runs in one
transaction with foreign keys disabled, and user_version is set to the schema version. When the
driver is not initialized, onConfigure runs before the work and onOpen runs after it, and the
driver is initialized.
Use it when a migration fails, for example after initialize() throws.
try {
driver.initialize()
} catch(e: SQLiteException) {
driver.recreateSchema()
}Do not call it inside a transaction. It throws an IllegalStateException there. It does not
repair a corrupt file. For that, use deleteDatabase().
deleteDatabase() is available on the JVM, Android, and native. It closes every connection, then
deletes the database file and its -journal, -wal, and -shm files. All data is lost. The next
query opens a new file, creates the schema, and runs onConfigure, onCreate, and onOpen. The
call waits for a statement or transaction in progress to complete. Use it when the file is corrupt.
try {
driver.initialize()
} catch(e: SQLiteException) {
driver.deleteDatabase()
driver.initialize()
}Do not call it inside a transaction. It throws an IllegalStateException there. For
AndroidxSqliteDatabaseType.Memory and AndroidxSqliteDatabaseType.Temporary, it only closes the
connections, which discards the database contents. On web, delete the file through the Origin Private
File System API after close() and awaitClosed().
To run code at specific schema versions during migration (the equivalent of SQLDelight's
AfterVersion), pass AndroidxSqliteAfterVersion instances to migrationCallbacks:
AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
migrationCallbacks = arrayOf(
AndroidxSqliteAfterVersion(afterVersion = 3) { driver ->
// Suspending. Runs inside the migration's transaction, on its writer connection.
driver.execute(null, "INSERT INTO settings(key, value) VALUES ('feature_x', 'on')", 0).await()
},
),
)The callback's block is suspend (SqlDriver) -> Unit. The driver bridges the migration's
coroutine context into it so DB ops reuse the migration's writer instead of trying to acquire a
fresh one (which would deadlock). If the callback throws, the entire migration transaction rolls
back.
A companion artifact provides Flow extensions that mirror
app.cash.sqldelight:coroutines-extensions,
but default the CoroutineContext parameter to EmptyCoroutineContext — the driver already
dispatches each query onto its own connection pool, so wrapping every mapper in a second
withContext(Dispatchers.IO) is redundant.
dependencies {
implementation("com.eygraber:sqldelight-coroutines-extensions:0.5.0-alpha.11")
}import com.eygraber.sqldelight.androidx.driver.coroutines.asFlow
import com.eygraber.sqldelight.androidx.driver.coroutines.mapToList
import com.eygraber.sqldelight.androidx.driver.coroutines.mapToOne
import com.eygraber.sqldelight.androidx.driver.coroutines.mapToOneOrNull
val usersFlow: Flow<List<User>> = database.userQueries.selectAll().asFlow().mapToList()
val userFlow: Flow<User?> = database.userQueries.selectById(id).asFlow().mapToOneOrNull()You can still pass an explicit CoroutineContext if you want the mapper to run somewhere
specific (e.g. Dispatchers.Main for UI state). If you prefer the sqldelight extensions,
they still work — just import from app.cash.sqldelight.coroutines instead.
For tests, use AndroidxSqliteDatabaseType.Memory — each driver instance gets its own isolated
database, nothing is written to disk, and there's nothing to clean up between tests. In-memory
and temporary databases automatically use SingleReaderWriter, so you don't need to configure
the concurrency model.
Pick the underlying SQLiteDriver based on where the test runs:
BundledSQLiteDriver() from
androidx.sqlite:sqlite-bundled. Works across all platforms with no Android runtime.AndroidSQLiteDriver() from androidx.sqlite:sqlite-framework
if you want to exercise the platform's SQLite; BundledSQLiteDriver() also works on device.[!IMPORTANT] For Android host (unit) tests — the ones that run on a JVM under
src/androidHostTest/src/test, not on a device — the Android variant ofandroidx.sqlite:sqlite-bundledis what gets resolved, and it doesn't ship the JVM native binariesBundledSQLiteDriver()needs. The test will load the driver, fail to find a native library for your host OS, and crash. You'll probably need to substitute the Android artifact with its-jvmcounterpart, but only for the unit test runtime classpath:import com.android.build.api.variant.HasUnitTest androidComponents { onVariants { variant -> (variant as HasUnitTest).unitTest?.let { unitTest -> with(unitTest.runtimeConfiguration.resolutionStrategy.dependencySubstitution) { substitute(module("androidx.sqlite:sqlite-bundled:<version>")) .using(module("androidx.sqlite:sqlite-bundled-jvm:<version>")) } } } }The production Android variant is left untouched — this only swaps in the JVM artifact for the host test JVM that Gradle spins up.
class UserRepositoryTest {
private lateinit var driver: SqlDriver
private lateinit var database: Database
@BeforeTest
fun setup() {
driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.Memory,
schema = Database.Schema,
)
database = Database(driver)
}
@AfterTest
fun tearDown() {
driver.close()
}
@Test
fun insertedUserCanBeQueried() = runTest {
database.userQueries.insert(id = 1, name = "Alec").await()
val user = database.userQueries.selectById(1).awaitAsOne()
assertEquals("Alec", user.name)
}
}A few things to keep in mind:
kotlinx.coroutines.test.runTest so suspending driver calls have a scope to
run in. The driver dispatches its own work onto Dispatchers.IO by default — runTest does not
control that scheduling, so tests run against real concurrency (which is what you want when
exercising the connection pool).QueryResult.AsyncValue because of
generateAsync = true. Use .await() on execute calls and awaitAsOne / awaitAsOneOrNull /
awaitAsList from app.cash.sqldelight:async-extensions for queries.driver.close() in @AfterTest (or use { }) so the driver's dispatcher and connection
pool are released between tests. close() does not wait for statements in progress. Call
awaitClosed() first when a test needs the connections to be closed.commonTest setup above works as-is — BundledSQLiteDriver and
AndroidxSqliteDatabaseType.Memory are both available in common code.If you need to test against a file-backed database instead, the setup from
Create the Driver and Database applies as-is — just swap
AndroidxSqliteDatabaseType.Memory for AndroidxSqliteDatabaseType.File(...) (or FileProvider
on Android). When tests run in parallel — across Gradle modules, or within a single module via
parallel test execution — make sure each test uses a unique database filename. Two tests opening
the same file concurrently will share state and contend on the same SQLite file locks, which leads
to flaky failures. A common pattern is to derive the name from the test class/method or a UUID,
and place the file in a per-test temp directory.
When using AndroidxSqliteDriver, the handling of foreign key constraints during database creation and migration is
managed to ensure data integrity.
If you have foreign key constraints enabled in your
AndroidxSqliteConfiguration (i.e. isForeignKeyConstraintsEnabled = true),
the driver will automatically disable them before executing the schema create or migrate operations.
This is done to prevent issues with table creation order and data manipulation during the migration process.
After the creation or migration is complete, foreign key constraints are re-enabled.
Furthermore, to verify the integrity of the foreign key relationships after these operations,
the driver performs an additional check. If isForeignKeyConstraintsCheckedAfterCreateOrUpdate
is true (which it is by default), a PRAGMA foreign_key_check is executed. If this check finds
any violations, an AndroidxSqliteDriver.ForeignKeyConstraintCheckException is thrown, detailing the
specific constraints that have been violated. This helps catch any inconsistencies in your data that might
have been introduced during the migration.
[!IMPORTANT]
By default, the first 100 violations will be parsed out of the result set ofPRAGMA foreign_key_checkand stored in theAndroidxSqliteDriver.ForeignKeyConstraintCheckException. If your use can result in a large number of violations you can adjust the max amount that will be processed viaAndroidxSqliteConfiguration.maxMigrationForeignKeyConstraintViolationsToReport.
SQLite supports several concurrency models that can significantly impact your application's performance. This driver
provides flexible connection pooling through the AndroidxSqliteConcurrencyModel interface.
The simplest model with one connection handling all operations:
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.SingleReaderWriter()
)Best for:
Dedicated reader connections for read-only access:
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReaders(
readerCount = 3 // Number of concurrent reader connections
)
)Best for:
Important: This model is designed for read-only access. No write operations (INSERT, UPDATE, DELETE) should be
performed. If you need write capabilities, use MultipleReadersSingleWriter in WAL mode instead.
The most flexible model that adapts based on journal mode:
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true, // Enable WAL mode for true concurrency
walCount = 3, // Reader connections when WAL is enabled (default: 3)
nonWalCount = 0, // Reader connections when WAL is disabled (default: 0)
)
)Best for:
PRAGMA synchronous = FULL is used)The optimal number of reader connections depends on your use case:
// Conservative (default)
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 3,
nonWalCount = 0,
)
// High-concurrency applications
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 8
)
// Memory-conscious applications
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 2
)On Android, you can use system-determined connection pool sizes:
// Based on SQLiteGlobal.getWALConnectionPoolSize()
fun getWALConnectionPoolSize(): Int {
val resources = Resources.getSystem()
val resId = resources.getIdentifier("db_connection_pool_size", "integer", "android")
return if (resId != 0) {
resources.getInteger(resId)
} else {
2 // Fallback default
}
}
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = getWALConnectionPoolSize(),
nonWalCount = 0,
)
)| Model | Memory Usage | Read Concurrency | Write Capability | Best Use Case |
|---|---|---|---|---|
| SingleReaderWriter | Lowest | None | Full | Simple apps |
| MultipleReaders | Medium | Excellent | None (read-only) | Read-only apps |
| MultipleReadersSingleWriter (WAL) | Higher | Excellent | Full | Production |
| MultipleReadersSingleWriter (non-WAL) | Medium | Limited | Full | Legacy/constrained |
[!NOTE]
In-Memory and temporary databases automatically useSingleReaderWritermodel regardless of configuration, as connection pooling provides no benefit for these database types.
[!NOTE]
On web (js/wasmJs) every model resolves to one connection, soreaderCountand the dispatcher provider have no effect there. Shared configuration incommonMainworks on web without changes.
The driver runs SQLite work on its own CoroutineDispatcher, sized to match the concurrency model
so a writer and each reader get their own slot. You don't need to use withContext(Dispatchers.IO)
when calling the driver — queries and transactions will suspend onto the driver's dispatcher
automatically, and switch back to your calling context when they return. Inside a transaction,
every operation stays on the same slot for the lifetime of the transaction, so you don't have to
worry about switching connections mid-transaction.
You pick how those threads are sourced by passing a dispatcherProvider when constructing the
concurrency model. Two are bundled:
// Default. Shares threads with Dispatchers.IO via limitedParallelism.
// Lower memory — no dedicated threads are created for the driver.
AndroidxSqliteConcurrencyModel.memoryOptimizedProvider()
// Allocates a dedicated thread pool (via newFixedThreadPoolContext).
// Each connection tends to stay on the same thread, which helps CPU cache locality
// at the cost of extra OS threads.
AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProviderCpuCacheHitOptimizedProvider is not available on web, because web targets have no threads. Access
to it throws UnsupportedOperationException there. Use memoryOptimizedProvider() in shared code
that also runs on web.
memoryOptimizedProvider() also accepts a base dispatcher if you'd rather derive parallelism from
somewhere other than Dispatchers.IO:
AndroidxSqliteConcurrencyModel.memoryOptimizedProvider(
dispatcher = myBackgroundDispatcher,
)The provider plugs into any concurrency model:
// Single connection, single dispatcher slot
AndroidxSqliteConcurrencyModel.SingleReaderWriter(
dispatcherProvider = AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProvider,
)
// Multiple readers (read-only), one slot per reader
AndroidxSqliteConcurrencyModel.MultipleReaders(
readerCount = 3,
dispatcherProvider = AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProvider,
)
// Multiple readers + single writer
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 3,
dispatcherProvider = AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProvider,
)driver.close() disposes the dispatcher, so you generally don't need to manage its lifetime.
If PRAGMA journal_mode = ... is executed through the driver, the connection pool will:
PRAGMA statementMultipleReadersSingleWriter model is in use, flip its isWal flag based on the
journal mode the statement returnedThis ensures all connections use the same journal mode and prevents inconsistencies.
MultipleReadersSingleWriter in WAL modeFor additional background on WAL mode and dispatcher tuning, see WAL & Dispatchers.
androidx.sqlite:sqlite-bundled doesn't ship a JS or wasmJs variant. On web, AndroidX provides
WebWorkerSQLiteDriver, a transport that delegates SQL execution to a Web Worker implementing
its protocol. The :opfs-driver module ships such a worker built on top of
@sqlite.org/sqlite-wasm's OPFS Sync Access Handle Pool VFS, so database files are
persisted in the browser's Origin Private File System. androidxSqliteOpfsDriver(...) returns
an OpfsSqliteDriver, a SQLiteDriver that delegates to WebWorkerSQLiteDriver and adds
close(). opfsWorker(...) returns the underlying OpfsWorker, which exposes the Worker and
the same close().
WebWorkerSQLiteDriver never terminates its worker. OpfsWorker.close() sends a close message
to the worker, which closes its databases, releases the SAH handles, releases its Web Locks, and
closes its BroadcastChannel before it acknowledges. The main thread then releases the
foreground Web Lock (in PauseOnHidden), removes its listeners and timers, closes the control
port, and terminates the worker. If the worker does not acknowledge within ten seconds, the main
thread proceeds anyway.
The SAHPool VFS keeps a flat pool of pre-allocated handles rather than honoring real OPFS paths, which is why a file name with a path separator is stored as one flat key.
The worker source is embedded as a string and instantiated from a Blob URL, so consumers don't
need to copy any JS resource into their bundle. The @sqlite.org/sqlite-wasm npm dependency is
exposed transitively (via api(npm(...))), so kotlinNpmInstall pulls it in automatically — the
worker dynamic-imports it at runtime against URLs resolved through webpack's
new URL(..., import.meta.url) syntax.
Browsers don't allow more than one connection to an OPFS database at a time, which is why the
multi-tab modes exist: PauseOnHidden coordinates with a same-origin Web Lock that the active
tab holds while visible; Shared elects a single owner tab via a BroadcastChannel and proxies
queries from non-owner tabs to it; Single simply refuses to open a second connection.
If the bundled OPFS worker doesn't fit your needs (e.g., a different VFS, custom multi-tab
coordination, or an existing sqlite-wasm setup), construct WebWorkerSQLiteDriver directly with
any Worker that implements its protocol:
// src/webMain/kotlin
actual fun createSqliteDriver(): SQLiteDriver = WebWorkerSQLiteDriver(myWorker())To use the bundled worker with your own WebWorkerSQLiteDriver, call opfsWorker() and pass
OpfsWorker.worker. Keep the OpfsWorker and call its close() after you close the driver.
The Apple test suite runs on macOS runners; everything else (Android host, JVM, Native, and the
js and wasmJs browser tests) runs on Linux. The driver, integration, and coroutines test
suites live in commonTest and run on every target, including both browser targets. Tests that
need threads, OpenFlags, or more than one connection stay in nonWebTest. The browser tests
drive Karma + headless Chrome — ./gradlew allTests will download Chrome via Kotlin's build
infrastructure on first run, but you need a working X-less Chromium-compatible binary on your
PATH for local runs to succeed (the standard google-chrome-stable / chromium package
satisfies it).
The SQLDelight AndroidX Driver provides a SQLDelight driver that wraps AndroidX Kotlin Multiplatform SQLite libraries. It works with any of the available implementations of AndroidX SQLite.
[!NOTE]
If you are migrating from version 0.0.17 or earlier, check out the migration guide
First setup SQLite dependencies following Set up SQLite for KMP.
Next add the dependency to your project.
repositories {
mavenCentral()
}
// Android/JVM
dependencies {
implementation("com.eygraber:sqldelight-androidx-driver:0.5.0-alpha.11")
}
// Multiplatform
commonMain.dependencies {
implementation("com.eygraber:sqldelight-androidx-driver:0.5.0-alpha.11")
}See Consuming Via Gradle for how to add a -SNAPSHOT release.
Next configure the database.
[!IMPORTANT]
generateAsyncmust equaltruesinceAndroidxSqliteDriveris a suspending driver.
[!IMPORTANT] If you're producing a dynamic framework for a Kotlin/Native target (typically iOS) and using
BundledSQLiteDriver(), setlinkSqlite = false. SQLDelight defaults to passing-lsqlite3to the linker so the framework links against the system SQLite, butandroidx.sqlite:sqlite-bundledalready statically bundles its own SQLite — leavinglinkSqlite = truemakeslldfail withlibrary not found for -lsqlite3.
sqldelight {
linkSqlite = false
databases {
register("Database") {
generateAsync = true
}
}
}[!TIP] AndroidX team recommends
BundledSQLiteDriverbut you can use whichever one suits your needs. See SQLite Driver Implementations for more info.
Android or JVM - Pass a File:
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File(File("my.db")),
schema = Database.Schema,
)
val database = Database(driver)
// More database stuff...Android - Use Context to create the file in the app's database directory:
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "my.db"),
schema = Database.Schema,
)
val database = Database(driver)
// More database stuff...Multiplatform - Create a database type factory:
// src/commonMain/kotlin
expect class DatabaseTypeFactory {
fun createDatabaseType(): AndroidxSqliteDatabaseType
}
fun createDatabase(databaseTypeFactory: DatabaseTypeFactory): Database {
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = databaseTypeFactory.createDatabaseType(),
schema = Database.Schema
)
val database = Database(driver)
// More database stuff...
}
// src/androidMain/kotlin
actual class DatabaseTypeFactory(private val context: Context) {
actual fun createDatabaseType(): AndroidxSqliteDatabaseType {
return AndroidxSqliteDatabaseType.FileProvider(context, "my.db")
}
}
// src/nativeMain/kotlin
actual class DatabaseTypeFactory {
actual fun createDatabaseType(): AndroidxSqliteDatabaseType {
return AndroidxSqliteDatabaseType.File("<absolute path to db file>")
}
}
// src/jvmMain/kotlin
actual class DatabaseTypeFactory {
actual fun createDatabaseType(): AndroidxSqliteDatabaseType {
AndroidxSqliteDatabaseType.File(File("my.db"))
}
}Add the :opfs-driver dependency to your web source set:
val webMain by getting {
dependencies {
implementation("com.eygraber:sqldelight-androidx-driver-opfs:0.5.0-alpha.11")
}
}On web, the SQLiteDriver constructor differs from non-web targets, so make createSqliteDriver
an expect/actual. The databaseType factory from the previous section stays the same:
// src/commonMain/kotlin
expect fun createSqliteDriver(): SQLiteDriver
fun createDatabase(databaseTypeFactory: DatabaseTypeFactory): Database {
val driver = AndroidxSqliteDriver(
driver = createSqliteDriver(),
databaseType = databaseTypeFactory.createDatabaseType(),
schema = Database.Schema,
)
return Database(driver)
}
// src/nonWebMain/kotlin (or separately in androidMain / jvmMain / nativeMain)
actual fun createSqliteDriver(): SQLiteDriver = BundledSQLiteDriver()
// src/webMain/kotlin (or separately in jsMain / wasmJsMain)
actual fun createSqliteDriver(): SQLiteDriver = androidxSqliteOpfsDriver()On web, build the databaseType with AndroidxSqliteDatabaseType.File("my.db") — the file is
persisted in the browser's Origin Private File System.
[!IMPORTANT] On web the file name is a flat key in the OPFS pool. The pool accepts a name like
"data/my.db"as one key and creates no directory. A name over 511 bytes fails to open.
[!NOTE] If your project only targets web, you don't need
expect/actual— just callandroidxSqliteOpfsDriver()and pass it toAndroidxSqliteDriver.
CI runs the test suites on both web targets. The js target works with the default Kotlin/JS
module kind, so useEsModules() is not required. The driver resolves the @sqlite.org/sqlite-wasm
files with new URL(..., import.meta.url). The Kotlin/JS webpack build rewrites these URLs into
bundled asset URLs.
androidxSqliteOpfsDriver() returns an OpfsSqliteDriver. It owns a Web Worker that holds the
OPFS handles, the multi-tab Web Locks, and the cross-tab channels. AndroidxSqliteDriver.close()
closes the connection and then closes the OpfsSqliteDriver, which releases all of these and
terminates the worker. Call it when you recreate the database, for example on logout.
val driver = AndroidxSqliteDriver(
driver = androidxSqliteOpfsDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
)
// later
driver.close()close() is idempotent. After it, open on the OpfsSqliteDriver fails with an
IllegalStateException, and queries that the worker had queued fail with an exception.
See Closing the driver for what happens to statements in progress.
Pass an OpfsMultiTabMode to androidxSqliteOpfsDriver(...) to pick how tabs coordinate access:
PauseOnHidden (default) — the active tab uses the database; backgrounded tabs pause and
queue their queries until they regain focus. Adds no per-query overhead. Use this when your
app is primarily used in one tab at a time, or if it is OK for queries to queue when your tab is not active.
If a tab is hidden while a transaction is open, the hand-off waits until that transaction ends.
The other tab gets the database after the commit or the rollback.Shared — every tab can run queries concurrently regardless of focus. One tab is elected
the database owner and serves queries on behalf of the others; ownership transfers
automatically when the owning tab closes. Use this when users may actively interact with
multiple tabs simultaneously, and you're willing to pay a cross-tab round-trip per query in
non-owner tabs.Single — only one tab can use the database at a time; opening a second tab while the
first is open will fail. Use this only when your app is guaranteed to run in a single tab.import com.eygraber.sqldelight.androidx.driver.opfs.OpfsMultiTabMode
import com.eygraber.sqldelight.androidx.driver.opfs.androidxSqliteOpfsDriver
val driver = androidxSqliteOpfsDriver(multiTabMode = OpfsMultiTabMode.Shared)In PauseOnHidden, queries from a backgrounded tab queue silently. Pass onLockStateChange to
render an indicator instead:
import com.eygraber.sqldelight.androidx.driver.opfs.OpfsLockState
import com.eygraber.sqldelight.androidx.driver.opfs.androidxSqliteOpfsDriver
val lockState = MutableStateFlow(OpfsLockState.Live)
val driver = androidxSqliteOpfsDriver(
onLockStateChange = { lockState.value = it },
)The callback fires once synchronously with the initial state, and again on each transition. In
Single and Shared modes it fires exactly once with Live. It does not fire after the driver
is closed.
For background on how the web driver is built and how to substitute your own worker if
:opfs-driver doesn't fit your needs, see Web driver design notes.
If you want to provide OpenFlags to the bundled or native driver, you can use:
Database(
AndroidxSqliteDriver(
connectionFactory = object : AndroidxSqliteConnectionFactory {
override val driver = BundledSQLiteDriver()
override suspend fun createConnection(name: String) =
driver.open(name, SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE)
},
databaseType = AndroidxSqliteDatabaseType.File("<absolute path to db file>"),
schema = Database.Schema,
)
)It will handle calling the create and migrate functions on your schema for you, and keep track of the database's version.
AndroidxSqliteDriver is a suspending driver, so SQLDelight must be configured with
generateAsync = true:
sqldelight {
databases {
register("Database") {
generateAsync = true
}
}
}With generateAsync = true, generated queries return QueryResult.AsyncValue rather than
materialized values — you must call .await() (or the awaitAsOne/awaitAsList/etc. helpers
from app.cash.sqldelight:async-extensions) to get the result:
val user: User = database.userQueries.selectById(id).awaitAsOne()
val users: List<User> = database.userQueries.selectAll().awaitAsList()All database calls are suspending, and the driver runs them on its own coroutine dispatchers —
you don't need to wrap calls in withContext(Dispatchers.IO). Learn more below.
AndroidxSqliteDriver accepts four optional callbacks, all invoked at most once on the first
interaction with the database:
AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
onConfigure = {
// Suspending. Runs before create/migrate. Use it to override pragmas that aren't
// covered by AndroidxSqliteConfiguration.
setForeignKeyConstraintsEnabled(true)
},
onCreate = { /* side effects after first create */ },
onUpdate = { old, new -> /* side effects after migration */ },
onOpen = { /* side effects after create or migrate */ },
)All four callbacks run at most once per driver instance, on the first database interaction,
guarded by an internal mutex — concurrent first-time callers all wait for them to complete before
any queries execute. If you close the driver and construct a new one against the same database,
onConfigure and onOpen run again for the new instance; onCreate and onUpdate only run if
the schema actually needs to be created or migrated.
onConfigure runs first, before any schema work. It's the right place to override pragmas
that aren't covered by AndroidxSqliteConfiguration.onCreate runs only when the database is created for the first time, after SqlSchema.create
has committed.onUpdate runs only when the schema version has increased, after SqlSchema.migrate has
committed.onOpen runs on every first interaction, after any create/migrate work.All four callbacks are suspend lambdas, so you can await() driver operations directly inside
them. Note that onCreate, onUpdate, and onOpen run after the create/migrate transaction
has committed — they aren't part of it. To seed data or run additional SQL inside the
create/migrate transaction, put it in your SqlSchema.create / SqlSchema.migrate, or use
migrationCallbacks (see below).
close() never blocks and never throws. Idle connections close immediately. A connection in use
closes when its coroutine releases it. New statements fail with an IllegalStateException.
On the JVM, Android, and native, a statement or transaction in progress completes, even when
close() runs inside the transaction. The dispatcher shuts down after the last connection closes.
On web, close() closes the OpfsSqliteDriver and its worker immediately. A paused driver can
hold a statement for as long as the tab is hidden, so the worker does not wait for it. A statement
that the worker has queued fails with an exception. The statement that the worker executes at that
moment completes.
awaitClosed() suspends until the last connection closes:
driver.close()
driver.awaitClosed()Call it before you open the same file with a new driver. Call it before you change the database files yourself.
The driver opens the database on the first query. initialize() does that work immediately and
throws what a first query would throw. Use it to catch a failed migration or a corrupt file at a
known point.
val driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
)
try {
driver.initialize()
} catch(e: SQLiteException) {
// decide what to do with the file
}A second call does nothing.
recreateSchema() drops every schema object, then runs SqlSchema.create and onCreate. The
dropped objects are tables, views, triggers, and indexes. All data is lost. The work runs in one
transaction with foreign keys disabled, and user_version is set to the schema version. When the
driver is not initialized, onConfigure runs before the work and onOpen runs after it, and the
driver is initialized.
Use it when a migration fails, for example after initialize() throws.
try {
driver.initialize()
} catch(e: SQLiteException) {
driver.recreateSchema()
}Do not call it inside a transaction. It throws an IllegalStateException there. It does not
repair a corrupt file. For that, use deleteDatabase().
deleteDatabase() is available on the JVM, Android, and native. It closes every connection, then
deletes the database file and its -journal, -wal, and -shm files. All data is lost. The next
query opens a new file, creates the schema, and runs onConfigure, onCreate, and onOpen. The
call waits for a statement or transaction in progress to complete. Use it when the file is corrupt.
try {
driver.initialize()
} catch(e: SQLiteException) {
driver.deleteDatabase()
driver.initialize()
}Do not call it inside a transaction. It throws an IllegalStateException there. For
AndroidxSqliteDatabaseType.Memory and AndroidxSqliteDatabaseType.Temporary, it only closes the
connections, which discards the database contents. On web, delete the file through the Origin Private
File System API after close() and awaitClosed().
To run code at specific schema versions during migration (the equivalent of SQLDelight's
AfterVersion), pass AndroidxSqliteAfterVersion instances to migrationCallbacks:
AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.File("my.db"),
schema = Database.Schema,
migrationCallbacks = arrayOf(
AndroidxSqliteAfterVersion(afterVersion = 3) { driver ->
// Suspending. Runs inside the migration's transaction, on its writer connection.
driver.execute(null, "INSERT INTO settings(key, value) VALUES ('feature_x', 'on')", 0).await()
},
),
)The callback's block is suspend (SqlDriver) -> Unit. The driver bridges the migration's
coroutine context into it so DB ops reuse the migration's writer instead of trying to acquire a
fresh one (which would deadlock). If the callback throws, the entire migration transaction rolls
back.
A companion artifact provides Flow extensions that mirror
app.cash.sqldelight:coroutines-extensions,
but default the CoroutineContext parameter to EmptyCoroutineContext — the driver already
dispatches each query onto its own connection pool, so wrapping every mapper in a second
withContext(Dispatchers.IO) is redundant.
dependencies {
implementation("com.eygraber:sqldelight-coroutines-extensions:0.5.0-alpha.11")
}import com.eygraber.sqldelight.androidx.driver.coroutines.asFlow
import com.eygraber.sqldelight.androidx.driver.coroutines.mapToList
import com.eygraber.sqldelight.androidx.driver.coroutines.mapToOne
import com.eygraber.sqldelight.androidx.driver.coroutines.mapToOneOrNull
val usersFlow: Flow<List<User>> = database.userQueries.selectAll().asFlow().mapToList()
val userFlow: Flow<User?> = database.userQueries.selectById(id).asFlow().mapToOneOrNull()You can still pass an explicit CoroutineContext if you want the mapper to run somewhere
specific (e.g. Dispatchers.Main for UI state). If you prefer the sqldelight extensions,
they still work — just import from app.cash.sqldelight.coroutines instead.
For tests, use AndroidxSqliteDatabaseType.Memory — each driver instance gets its own isolated
database, nothing is written to disk, and there's nothing to clean up between tests. In-memory
and temporary databases automatically use SingleReaderWriter, so you don't need to configure
the concurrency model.
Pick the underlying SQLiteDriver based on where the test runs:
BundledSQLiteDriver() from
androidx.sqlite:sqlite-bundled. Works across all platforms with no Android runtime.AndroidSQLiteDriver() from androidx.sqlite:sqlite-framework
if you want to exercise the platform's SQLite; BundledSQLiteDriver() also works on device.[!IMPORTANT] For Android host (unit) tests — the ones that run on a JVM under
src/androidHostTest/src/test, not on a device — the Android variant ofandroidx.sqlite:sqlite-bundledis what gets resolved, and it doesn't ship the JVM native binariesBundledSQLiteDriver()needs. The test will load the driver, fail to find a native library for your host OS, and crash. You'll probably need to substitute the Android artifact with its-jvmcounterpart, but only for the unit test runtime classpath:import com.android.build.api.variant.HasUnitTest androidComponents { onVariants { variant -> (variant as HasUnitTest).unitTest?.let { unitTest -> with(unitTest.runtimeConfiguration.resolutionStrategy.dependencySubstitution) { substitute(module("androidx.sqlite:sqlite-bundled:<version>")) .using(module("androidx.sqlite:sqlite-bundled-jvm:<version>")) } } } }The production Android variant is left untouched — this only swaps in the JVM artifact for the host test JVM that Gradle spins up.
class UserRepositoryTest {
private lateinit var driver: SqlDriver
private lateinit var database: Database
@BeforeTest
fun setup() {
driver = AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.Memory,
schema = Database.Schema,
)
database = Database(driver)
}
@AfterTest
fun tearDown() {
driver.close()
}
@Test
fun insertedUserCanBeQueried() = runTest {
database.userQueries.insert(id = 1, name = "Alec").await()
val user = database.userQueries.selectById(1).awaitAsOne()
assertEquals("Alec", user.name)
}
}A few things to keep in mind:
kotlinx.coroutines.test.runTest so suspending driver calls have a scope to
run in. The driver dispatches its own work onto Dispatchers.IO by default — runTest does not
control that scheduling, so tests run against real concurrency (which is what you want when
exercising the connection pool).QueryResult.AsyncValue because of
generateAsync = true. Use .await() on execute calls and awaitAsOne / awaitAsOneOrNull /
awaitAsList from app.cash.sqldelight:async-extensions for queries.driver.close() in @AfterTest (or use { }) so the driver's dispatcher and connection
pool are released between tests. close() does not wait for statements in progress. Call
awaitClosed() first when a test needs the connections to be closed.commonTest setup above works as-is — BundledSQLiteDriver and
AndroidxSqliteDatabaseType.Memory are both available in common code.If you need to test against a file-backed database instead, the setup from
Create the Driver and Database applies as-is — just swap
AndroidxSqliteDatabaseType.Memory for AndroidxSqliteDatabaseType.File(...) (or FileProvider
on Android). When tests run in parallel — across Gradle modules, or within a single module via
parallel test execution — make sure each test uses a unique database filename. Two tests opening
the same file concurrently will share state and contend on the same SQLite file locks, which leads
to flaky failures. A common pattern is to derive the name from the test class/method or a UUID,
and place the file in a per-test temp directory.
When using AndroidxSqliteDriver, the handling of foreign key constraints during database creation and migration is
managed to ensure data integrity.
If you have foreign key constraints enabled in your
AndroidxSqliteConfiguration (i.e. isForeignKeyConstraintsEnabled = true),
the driver will automatically disable them before executing the schema create or migrate operations.
This is done to prevent issues with table creation order and data manipulation during the migration process.
After the creation or migration is complete, foreign key constraints are re-enabled.
Furthermore, to verify the integrity of the foreign key relationships after these operations,
the driver performs an additional check. If isForeignKeyConstraintsCheckedAfterCreateOrUpdate
is true (which it is by default), a PRAGMA foreign_key_check is executed. If this check finds
any violations, an AndroidxSqliteDriver.ForeignKeyConstraintCheckException is thrown, detailing the
specific constraints that have been violated. This helps catch any inconsistencies in your data that might
have been introduced during the migration.
[!IMPORTANT]
By default, the first 100 violations will be parsed out of the result set ofPRAGMA foreign_key_checkand stored in theAndroidxSqliteDriver.ForeignKeyConstraintCheckException. If your use can result in a large number of violations you can adjust the max amount that will be processed viaAndroidxSqliteConfiguration.maxMigrationForeignKeyConstraintViolationsToReport.
SQLite supports several concurrency models that can significantly impact your application's performance. This driver
provides flexible connection pooling through the AndroidxSqliteConcurrencyModel interface.
The simplest model with one connection handling all operations:
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.SingleReaderWriter()
)Best for:
Dedicated reader connections for read-only access:
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReaders(
readerCount = 3 // Number of concurrent reader connections
)
)Best for:
Important: This model is designed for read-only access. No write operations (INSERT, UPDATE, DELETE) should be
performed. If you need write capabilities, use MultipleReadersSingleWriter in WAL mode instead.
The most flexible model that adapts based on journal mode:
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true, // Enable WAL mode for true concurrency
walCount = 3, // Reader connections when WAL is enabled (default: 3)
nonWalCount = 0, // Reader connections when WAL is disabled (default: 0)
)
)Best for:
PRAGMA synchronous = FULL is used)The optimal number of reader connections depends on your use case:
// Conservative (default)
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 3,
nonWalCount = 0,
)
// High-concurrency applications
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 8
)
// Memory-conscious applications
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 2
)On Android, you can use system-determined connection pool sizes:
// Based on SQLiteGlobal.getWALConnectionPoolSize()
fun getWALConnectionPoolSize(): Int {
val resources = Resources.getSystem()
val resId = resources.getIdentifier("db_connection_pool_size", "integer", "android")
return if (resId != 0) {
resources.getInteger(resId)
} else {
2 // Fallback default
}
}
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = getWALConnectionPoolSize(),
nonWalCount = 0,
)
)| Model | Memory Usage | Read Concurrency | Write Capability | Best Use Case |
|---|---|---|---|---|
| SingleReaderWriter | Lowest | None | Full | Simple apps |
| MultipleReaders | Medium | Excellent | None (read-only) | Read-only apps |
| MultipleReadersSingleWriter (WAL) | Higher | Excellent | Full | Production |
| MultipleReadersSingleWriter (non-WAL) | Medium | Limited | Full | Legacy/constrained |
[!NOTE]
In-Memory and temporary databases automatically useSingleReaderWritermodel regardless of configuration, as connection pooling provides no benefit for these database types.
[!NOTE]
On web (js/wasmJs) every model resolves to one connection, soreaderCountand the dispatcher provider have no effect there. Shared configuration incommonMainworks on web without changes.
The driver runs SQLite work on its own CoroutineDispatcher, sized to match the concurrency model
so a writer and each reader get their own slot. You don't need to use withContext(Dispatchers.IO)
when calling the driver — queries and transactions will suspend onto the driver's dispatcher
automatically, and switch back to your calling context when they return. Inside a transaction,
every operation stays on the same slot for the lifetime of the transaction, so you don't have to
worry about switching connections mid-transaction.
You pick how those threads are sourced by passing a dispatcherProvider when constructing the
concurrency model. Two are bundled:
// Default. Shares threads with Dispatchers.IO via limitedParallelism.
// Lower memory — no dedicated threads are created for the driver.
AndroidxSqliteConcurrencyModel.memoryOptimizedProvider()
// Allocates a dedicated thread pool (via newFixedThreadPoolContext).
// Each connection tends to stay on the same thread, which helps CPU cache locality
// at the cost of extra OS threads.
AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProviderCpuCacheHitOptimizedProvider is not available on web, because web targets have no threads. Access
to it throws UnsupportedOperationException there. Use memoryOptimizedProvider() in shared code
that also runs on web.
memoryOptimizedProvider() also accepts a base dispatcher if you'd rather derive parallelism from
somewhere other than Dispatchers.IO:
AndroidxSqliteConcurrencyModel.memoryOptimizedProvider(
dispatcher = myBackgroundDispatcher,
)The provider plugs into any concurrency model:
// Single connection, single dispatcher slot
AndroidxSqliteConcurrencyModel.SingleReaderWriter(
dispatcherProvider = AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProvider,
)
// Multiple readers (read-only), one slot per reader
AndroidxSqliteConcurrencyModel.MultipleReaders(
readerCount = 3,
dispatcherProvider = AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProvider,
)
// Multiple readers + single writer
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 3,
dispatcherProvider = AndroidxSqliteConcurrencyModel.CpuCacheHitOptimizedProvider,
)driver.close() disposes the dispatcher, so you generally don't need to manage its lifetime.
If PRAGMA journal_mode = ... is executed through the driver, the connection pool will:
PRAGMA statementMultipleReadersSingleWriter model is in use, flip its isWal flag based on the
journal mode the statement returnedThis ensures all connections use the same journal mode and prevents inconsistencies.
MultipleReadersSingleWriter in WAL modeFor additional background on WAL mode and dispatcher tuning, see WAL & Dispatchers.
androidx.sqlite:sqlite-bundled doesn't ship a JS or wasmJs variant. On web, AndroidX provides
WebWorkerSQLiteDriver, a transport that delegates SQL execution to a Web Worker implementing
its protocol. The :opfs-driver module ships such a worker built on top of
@sqlite.org/sqlite-wasm's OPFS Sync Access Handle Pool VFS, so database files are
persisted in the browser's Origin Private File System. androidxSqliteOpfsDriver(...) returns
an OpfsSqliteDriver, a SQLiteDriver that delegates to WebWorkerSQLiteDriver and adds
close(). opfsWorker(...) returns the underlying OpfsWorker, which exposes the Worker and
the same close().
WebWorkerSQLiteDriver never terminates its worker. OpfsWorker.close() sends a close message
to the worker, which closes its databases, releases the SAH handles, releases its Web Locks, and
closes its BroadcastChannel before it acknowledges. The main thread then releases the
foreground Web Lock (in PauseOnHidden), removes its listeners and timers, closes the control
port, and terminates the worker. If the worker does not acknowledge within ten seconds, the main
thread proceeds anyway.
The SAHPool VFS keeps a flat pool of pre-allocated handles rather than honoring real OPFS paths, which is why a file name with a path separator is stored as one flat key.
The worker source is embedded as a string and instantiated from a Blob URL, so consumers don't
need to copy any JS resource into their bundle. The @sqlite.org/sqlite-wasm npm dependency is
exposed transitively (via api(npm(...))), so kotlinNpmInstall pulls it in automatically — the
worker dynamic-imports it at runtime against URLs resolved through webpack's
new URL(..., import.meta.url) syntax.
Browsers don't allow more than one connection to an OPFS database at a time, which is why the
multi-tab modes exist: PauseOnHidden coordinates with a same-origin Web Lock that the active
tab holds while visible; Shared elects a single owner tab via a BroadcastChannel and proxies
queries from non-owner tabs to it; Single simply refuses to open a second connection.
If the bundled OPFS worker doesn't fit your needs (e.g., a different VFS, custom multi-tab
coordination, or an existing sqlite-wasm setup), construct WebWorkerSQLiteDriver directly with
any Worker that implements its protocol:
// src/webMain/kotlin
actual fun createSqliteDriver(): SQLiteDriver = WebWorkerSQLiteDriver(myWorker())To use the bundled worker with your own WebWorkerSQLiteDriver, call opfsWorker() and pass
OpfsWorker.worker. Keep the OpfsWorker and call its close() after you close the driver.
The Apple test suite runs on macOS runners; everything else (Android host, JVM, Native, and the
js and wasmJs browser tests) runs on Linux. The driver, integration, and coroutines test
suites live in commonTest and run on every target, including both browser targets. Tests that
need threads, OpenFlags, or more than one connection stay in nonWebTest. The browser tests
drive Karma + headless Chrome — ./gradlew allTests will download Chrome via Kotlin's build
infrastructure on first run, but you need a working X-less Chromium-compatible binary on your
PATH for local runs to succeed (the standard google-chrome-stable / chromium package
satisfies it).