Local Storage: SQLDelight, Room, SQLite Drivers, DataStore and Settings
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
An offline-first app is only as good as its local storage. TrailMate must show the saved trail list with no network, remember
which trails are favorites, and keep a handful of user preferences (distance units, offline-only mode) across launches.
In a native app each platform solves this separately — Room and DataStore on Android, Core Data or SwiftData and
UserDefaults on iOS — and the three implementations drift. In Kotlin Multiplatform the whole persistence layer can live
in commonMain, so a single schema, a single set of queries and a single preferences repository serve every target.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4.1,
Ktor 3.6.0, SQLDelight 2.4.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6,
Room 3 (androidx.room3) 3.0.3, Mokkery 3.5.0 and Turbine 1.2.1. Swift export and SwiftPM import are Alpha;
Kotlin/Wasm and Compose Multiplatform for web are Beta. SQLDelight 2.4, Room 3 3.0.3, androidx.sqlite 2.7.1 and DataStore 1.2.1 are Stable; the web SQLite driver and the
multiplatform-settings Flow API are called out below as pre-stable.
Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s
TrailMate app (package com.example.trailmate). The Android-only counterparts are on
Room Database and Paging and
Local Storage: DataStore and Files; this page does not repeat
Room or DataStore basics, it shows what changes when the code is shared. The Kotlin language and coroutines are not
re-taught: see Kotlin Flows.
The repository from Architecture, Shared ViewModels and State
is the only class that knows both the network (see Networking with the Ktor Client)
and the disk. Relational data (trails, favorites) goes to SQLite; small values (units, offline-only flag) go to DataStore.
Both are exposed as Flow, which also makes them easy to consume from Swift (see
Consuming Coroutines and Flow from Swift).
SQLDelight 2.4
SQLDelight inverts the usual ORM direction: you write SQL, and a Gradle plugin generates the Kotlin API. The schema
and every query live in .sq files; the plugin parses them at build time, so a typo in a column name or a mismatch
between a query and the schema is a build error, not a runtime crash. The generated code is plain common Kotlin
that talks to a SqlDriver interface, and each platform supplies the driver.
Gradle Setup
The plugin id is app.cash.sqldelight (version 2.x; the 1.x id com.squareup.sqldelight is the legacy one and is not
compatible). The shared module uses the Android-KMP library plugin, as on
The Android Target.
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.sqldelight)
}
kotlin {
android { namespace = "com.example.trailmate.shared"; compileSdk = 37; minSdk = 26 }
iosArm64(); iosSimulatorArm64(); jvm("desktop")
sourceSets {
commonMain.dependencies {
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines)
}
androidMain.dependencies { implementation(libs.sqldelight.android.driver) }
iosMain.dependencies { implementation(libs.sqldelight.native.driver) }
val desktopMain by getting { dependencies { implementation(libs.sqldelight.sqlite.driver) } }
}
}
sqldelight {
databases {
create("TrailMateDatabase") {
packageName.set("com.example.trailmate.db")
schemaOutputDirectory.set(file("src/commonMain/sqldelight/databases"))
verifyMigrations.set(true)
}
}
}
The version-catalog aliases map to app.cash.sqldelight:runtime, coroutines-extensions, android-driver, native-driver and
sqlite-driver (all 2.4.0) and to the plugin app.cash.sqldelight. The create("TrailMateDatabase") block names the generated database class. Sources default to
src/commonMain/sqldelight/, and files inside it are organised in directories that mirror the package.
The .sq File and Generated Queries
A .sq file starts with the table definition, then lists labelled statements. Each label becomes a function on the
generated TrailMateDatabase.trailQueries object.
-- sharedLogic/src/commonMain/sqldelight/com/example/trailmate/db/Trail.sq
import com.example.trailmate.domain.Difficulty;
CREATE TABLE TrailRow (
id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, region TEXT NOT NULL,
lengthKm REAL NOT NULL, difficulty TEXT AS Difficulty NOT NULL, photoUrl TEXT,
isFavorite INTEGER AS Boolean NOT NULL DEFAULT 0
);
selectAll:
SELECT * FROM TrailRow ORDER BY name;
upsert:
INSERT INTO TrailRow(id, name, region, lengthKm, difficulty, photoUrl)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, region = excluded.region, lengthKm = excluded.lengthKm,
difficulty = excluded.difficulty, photoUrl = excluded.photoUrl;
setFavorite:
UPDATE TrailRow SET isFavorite = ? WHERE id = ?;
Several things happen here. The import line and TEXT AS Difficulty tell SQLDelight to map the column to the domain
enum; it will demand a column adapter when the database is constructed (shown next). INTEGER AS Boolean needs no
adapter in 2.x. The upsert updates only the server-owned columns, so a network refresh never resets the local isFavorite flag, a
classic offline-first bug (INSERT OR REPLACE would delete and re-insert the row). The generated Kotlin has one data class per table (TrailRow) and one function per label:
selectAll(), upsert(…) and setFavorite(isFavorite, id).
The database is built from a driver plus the adapters:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/local/DatabaseFactory.kt
package com.example.trailmate.data.local
import app.cash.sqldelight.EnumColumnAdapter
import app.cash.sqldelight.db.SqlDriver
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.db.TrailRow
fun createTrailMateDatabase(driver: SqlDriver): TrailMateDatabase =
TrailMateDatabase(
driver = driver,
TrailRowAdapter = TrailRow.Adapter(difficultyAdapter = EnumColumnAdapter()),
)
EnumColumnAdapter stores the enum by name: reordering entries is safe, renaming one is a data migration.
Drivers per Platform
A SqlDriver is the only platform-specific piece. SQLDelight ships one per platform family, and each is created in
platform code (or a Koin platform module, see
Dependency Injection with Koin and Metro) and
passed to the common factory above.
| Target | Artifact and class | Notes |
|---|---|---|
Android |
|
Uses the framework SQLite and |
iOS, macOS and other Apple targets |
|
Uses the system SQLite; stored in the app’s Application Support directory. |
JVM desktop |
|
JDBC with a bundled SQLite; you choose the file path. |
Kotlin/JS and Wasm |
|
Asynchronous only; needs |
Each is a one-line call in platform code, for example AndroidSqliteDriver(TrailMateDatabase.Schema, context, "trailmate.db") in
androidMain and NativeSqliteDriver(TrailMateDatabase.Schema, "trailmate.db") in iosMain, and the result is passed to
createTrailMateDatabase.
For tests, JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY, schema = …) gives an isolated in-memory database on the JVM;
see Testing Multiplatform Code.
Coroutines Extensions: Queries as Flow
The coroutines-extensions artifact turns any generated query into a Flow that re-emits whenever a table the query
reads is written. This is what makes the UI reactive without a manual refresh call.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/local/TrailLocalDataSource.kt
package com.example.trailmate.data.local
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.domain.Trail
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class TrailLocalDataSource(
database: TrailMateDatabase,
private val io: CoroutineDispatcher = Dispatchers.Default,
) {
private val queries = database.trailQueries
fun observeTrails(): Flow<List<Trail>> =
queries.selectAll()
.asFlow()
.mapToList(io)
.map { rows -> rows.map { it.toDomain() } }
suspend fun upsertAll(trails: List<Trail>) = withContext(io) {
queries.transaction {
trails.forEach { queries.upsert(it.id, it.name, it.region, it.lengthKm, it.difficulty, it.photoUrl) }
}
}
}
Dispatchers.Default is used for the query work because it exists on every target and the driver does the blocking I/O itself; inject the dispatcher so tests can substitute a test dispatcher. Other helpers in
the extensions are mapToOne, mapToOneOrNull and mapToOneNotNull. Writes should be wrapped in transaction { }, which
batches the change notifications into one emission instead of one per row. The dispatcher rules for each platform are on
Coroutines, Flow and Concurrency in Shared Code.
Migrations
Once an app ships, the schema must evolve without wiping user data. The .sq files are the current schema and each .sqm
file is one step: 1.sqm migrates version 1 to 2, 2.sqm migrates 2 to 3, and the database version is the number of
migration files plus one.
-- sharedLogic/src/commonMain/sqldelight/com/example/trailmate/db/1.sqm
ALTER TABLE TrailRow ADD COLUMN elevationGainM INTEGER NOT NULL DEFAULT 0;
Also add the column to the CREATE TABLE in Trail.sq, so fresh and upgraded installs match. With schemaOutputDirectory
and verifyMigrations.set(true) the build checks exactly that: the generate<SourceSet>TrailMateDatabaseSchema task writes a
.db snapshot per released version, and verify<SourceSet>TrailMateDatabaseMigration replays the migrations on it and fails
on any difference. Commit the snapshots; the drivers run pending migrations through Schema.migrate.
Room 3 for Kotlin Multiplatform
Room 3 is the next major version of Jetpack Room, published under the androidx.room3 package. It is Kotlin-first, KSP-only
(no Java code generation), built on the androidx.sqlite driver API instead of SupportSQLiteDatabase, and works in
common code. Room 2.8.5 (androidx.room) also supports KMP for projects that cannot migrate yet. The annotations are the
ideas from Room Database and Paging under new package names. Artifact names
and builder signatures follow the Room 3 3.0.3 release notes; recheck them when you bump the version, because Room 3 is young.
Gradle Setup: KSP per Target
Room’s compiler is a KSP processor, and in a multiplatform module KSP is configured once per target, so the generated
actual constructor exists for each one (see
Dependencies, klibs.io, cinterop and SwiftPM Import).
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.ksp)
alias(libs.plugins.room3)
}
kotlin {
// android { ... }, iosArm64(), iosSimulatorArm64() and jvm("desktop") as in the SQLDelight module
sourceSets {
commonMain.dependencies {
implementation(libs.room3.runtime)
implementation(libs.sqlite.bundled)
}
}
}
room3 {
schemaDirectory("$projectDir/schemas")
}
dependencies {
add("kspAndroid", libs.room3.compiler)
add("kspIosArm64", libs.room3.compiler)
add("kspIosSimulatorArm64", libs.room3.compiler)
add("kspDesktop", libs.room3.compiler)
}
The Room artifacts are androidx.room3:room3-runtime and room3-compiler (3.0.3), the plugin id is androidx.room3, and
androidx.sqlite:sqlite-bundled supplies the driver. The Room Gradle plugin exports the schema JSON per version into schemaDirectory; commit those files, because they power
auto-migrations and migration tests. The KSP version string must match the Kotlin version (the example value is
illustrative — pick the release that matches Kotlin 2.4.20 from the KSP releases page).
Entity, DAO and Database
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/room/TrailDao.kt
package com.example.trailmate.data.room
import androidx.room3.Dao
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "trails")
data class TrailEntity(
@PrimaryKey val id: String, val name: String, val region: String, val lengthKm: Double,
val difficulty: String, val photoUrl: String?, val isFavorite: Boolean = false,
)
@Dao
interface TrailDao {
@Query("SELECT * FROM trails ORDER BY name")
fun observeAll(): Flow<List<TrailEntity>>
@Upsert
suspend fun upsertAll(trails: List<TrailEntity>)
@Query("UPDATE trails SET isFavorite = :favorite WHERE id = :id")
suspend fun setFavorite(id: String, favorite: Boolean)
}
Two rules separate shared Room from Android-only Room. First, a DAO function must be suspend or return a Flow.
Blocking functions are rejected by the compiler on the non-Android targets, because Native, desktop and web drivers do the
work off the calling thread; on Android the same rule applies in Room 3. Writes are suspend, reads are either suspend
(one-shot) or Flow (observed, re-emitting when the table changes). Second, Room does not use reflection, so it
cannot instantiate the database class by name on Native; that is what the constructor class below is for.
@ConstructedBy and RoomDatabaseConstructor
On Android Room can look the generated database up reflectively. Kotlin/Native has no such reflection, so you declare an
expect object implementing RoomDatabaseConstructor; KSP generates the actual for every target.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/room/TrailMateRoomDatabase.kt
package com.example.trailmate.data.room
import androidx.room3.ConstructedBy
import androidx.room3.Database
import androidx.room3.RoomDatabase
import androidx.room3.RoomDatabaseConstructor
@Database(entities = [TrailEntity::class], version = 1)
@ConstructedBy(TrailMateRoomDatabaseConstructor::class)
abstract class TrailMateRoomDatabase : RoomDatabase() {
abstract fun trailDao(): TrailDao
}
// KSP generates the actual implementations for every target.
@Suppress("KotlinNoActualForExpect")
expect object TrailMateRoomDatabaseConstructor : RoomDatabaseConstructor<TrailMateRoomDatabase> {
override fun initialize(): TrailMateRoomDatabase
}
Forgetting @ConstructedBy is the most common first failure: the app compiles for Android and then crashes on iOS with a
missing-constructor error. The expect object mechanism itself is explained on
expect/actual and Calling Platform APIs.
Platform Database Builders and BundledSQLiteDriver
Each platform builds the database with Room.databaseBuilder, giving it a file location, and the shared code finishes the
builder with a driver and a coroutine context.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/room/RoomFactory.kt
package com.example.trailmate.data.room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
fun buildTrailMateDatabase(
builder: RoomDatabase.Builder<TrailMateRoomDatabase>,
): TrailMateRoomDatabase =
builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.Default)
.build()
On Android the builder is Room.databaseBuilder<TrailMateRoomDatabase>(context, context.getDatabasePath("trailmate_room.db").absolutePath).
On iOS it is Room.databaseBuilder<TrailMateRoomDatabase>(name = documentsPath + "/trailmate_room.db"), with documentsPath obtained
from NSFileManager exactly as in the DataStore path producer below.
BundledSQLiteDriver ships its own copy of SQLite inside the app. That is a deliberate trade: roughly one to a few
megabytes of binary per platform, in exchange for the same SQLite version on every device and OS release (system SQLite on
older systems may lack newer features such as RETURNING). It is the recommended Room driver in KMP and the only one that works on JVM desktop.
What Stays Android-Only
In a shared module avoid LiveData and RxJava DAO return types, SupportSQLite* APIs, openHelperFactory, @RawQuery
with a SupportSQLiteQuery, and createFromAsset; keep them in androidMain (see
Room Database and Paging). Auto-migrations, type converters, @Upsert,
@Relation and Flow queries all work in common code.
The androidx.sqlite Drivers
Room 3 does not talk to SQLite directly; it talks to the small androidx.sqlite driver interface
(SQLiteDriver, SQLiteConnection, SQLiteStatement). Choosing a driver is how you choose which SQLite engine runs.
You can also use the drivers directly, without Room, for raw SQL.
| Driver | Artifact | Platforms | Which SQLite it uses |
|---|---|---|---|
|
|
Android |
The framework SQLite built into the OS, so its version depends on the Android release. |
|
|
iOS, macOS and other Kotlin/Native targets |
The SQLite shipped by the operating system. |
|
|
Android, iOS and Apple, JVM desktop, Linux and other native targets |
A SQLite compiled into your app; identical everywhere. Recommended default. |
|
|
Kotlin/JS and Kotlin/Wasm |
A SQLite compiled to WebAssembly running in a Web Worker. Newer and less proven than the others; treat it as pre-stable (Alpha-level) and check its status in the release notes before depending on it. |
Web SQL is asynchronous and needs npm and bundler setup (see Web Targets);
for small values on wasmJs use multiplatform-settings, below.
Preferences DataStore in KMP
DataStore stores small typed values asynchronously and exposes them as a Flow, replacing SharedPreferences. Its KMP
build uses Okio for file access, so the same code runs on Android, iOS, desktop JVM and Linux/macOS native. Only
Preferences DataStore is documented for KMP here (datastore-preferences-core); there is no browser implementation, so
a wasmJs app uses multiplatform-settings instead. DataStore 1.2.1 is Stable.
Creating the DataStore: Path Producers per Platform
The Gradle dependency is androidx.datastore:datastore-preferences-core 1.2.1 in commonMain. The common code needs only a file path. PreferenceDataStoreFactory.createWithPath builds the store on top of
OkioStorage (the internal storage that reads and writes okio.Path files), and the file name must end in
.preferences_pb. Each platform decides where that path points.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/prefs/DataStoreFactory.kt
package com.example.trailmate.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import okio.Path.Companion.toPath
internal const val DATA_STORE_FILE_NAME = "trailmate.preferences_pb"
fun createDataStore(producePath: () -> String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
On Android the producer is createDataStore { context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath }, and on iOS it is the
documents directory:
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/data/prefs/DataStore.ios.kt
package com.example.trailmate.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
@OptIn(ExperimentalForeignApi::class)
fun createIosDataStore(): DataStore<Preferences> = createDataStore {
val dir = NSFileManager.defaultManager.URLForDirectory(
NSDocumentDirectory, NSUserDomainMask, appropriateForURL = null, create = false, error = null,
)
requireNotNull(dir).path + "/" + DATA_STORE_FILE_NAME
}
Under the hood this is OkioStorage; on the JVM FileStorage is the java.io.File equivalent, and you pick a storage explicitly
only for typed (serializer-based) stores built with DataStoreFactory.create. Keep one DataStore instance per file for
the whole process (a Koin single): two instances on one file throw an IllegalStateException.
The UserPreferencesRepository
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/prefs/UserPreferencesRepository.kt
package com.example.trailmate.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
enum class DistanceUnit { KILOMETERS, MILES }
data class UserPreferences(
val unit: DistanceUnit = DistanceUnit.KILOMETERS, val offlineOnly: Boolean = false,
)
class UserPreferencesRepository(private val dataStore: DataStore<Preferences>) {
private object Keys {
val unit = stringPreferencesKey("distance_unit")
val offlineOnly = booleanPreferencesKey("offline_only")
}
val preferences: Flow<UserPreferences> = dataStore.data.map { p ->
UserPreferences(
unit = p[Keys.unit]?.let(DistanceUnit::valueOf) ?: DistanceUnit.KILOMETERS,
offlineOnly = p[Keys.offlineOnly] ?: false,
)
}
suspend fun setUnit(unit: DistanceUnit) = dataStore.edit { it[Keys.unit] = unit.name }.let { }
suspend fun setOfflineOnly(enabled: Boolean) = dataStore.edit { it[Keys.offlineOnly] = enabled }.let { }
}
dataStore.data emits the current values on collection and again after every edit { }; edit is transactional and
suspend, so it runs off the main thread. Read failures surface as IOException in the Flow (add .catch) and a corrupt
file can be replaced through a corruptionHandler passed to createWithPath.
From SwiftUI, with SKIE the Flow is an AsyncSequence (see
Consuming Coroutines and Flow from Swift):
// iosApp/iosApp/SettingsViewModel.swift
import SwiftUI
import SharedLogic
struct SettingsView: View {
let repository: UserPreferencesRepository
@State private var offlineOnly = false
var body: some View {
Toggle("Offline only", isOn: $offlineOnly)
.onChange(of: offlineOnly) { _, value in
Task { try? await repository.setOfflineOnly(enabled: value) }
}
.task {
for await prefs in repository.preferences { offlineOnly = prefs.offlineOnly }
}
}
}
The loop runs inside .task { }, so SwiftUI cancels it when the view disappears; a loop started in init would leak. SwiftUI basics are on SwiftUI Views and Modifiers.
See Android Developers — DataStore for KMP and KMP-NativeCoroutines for the alternative Swift bridge.
multiplatform-settings for Simple Key-Value Storage
When you need a synchronous getBoolean("key", default) call, or a store that also works in the browser,
multiplatform-settings (com.russhwolf:multiplatform-settings
version 1.3.0) wraps the native stores: SharedPreferences on Android, NSUserDefaults on iOS, java.util.prefs on the JVM and
localStorage on JS and Wasm.
The API is settings.getBoolean("onboarding_done", false) and settings.putBoolean("onboarding_done", true), or the
operator forms settings["onboarding_done", false] and settings["onboarding_done"] = true from com.russhwolf.settings.get
and set; TrailMate’s OnboardingStore wraps them for the first-run flag.
The multiplatform-settings-no-arg artifact adds a Settings() factory that needs no platform arguments; otherwise create
SharedPreferencesSettings(prefs) or NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults) in platform code. The
multiplatform-settings-coroutines artifact adds Flow-based observation, but that API is marked Experimental
(@ExperimentalSettingsApi), so prefer DataStore for observed preferences.
Never store secrets here. The values are plain text on disk and NSUserDefaults and SharedPreferences are included
in device backups. Tokens belong in the Android Keystore and the iOS Keychain.
Files with kotlinx-io and Okio
Some data is neither relational nor a preference: an exported GPX route, a downloaded map tile, a cached JSON blob. Two libraries give common file access.
-
kotlinx-io (0.9.1, the JetBrains library, pre-1.0) offers
SystemFileSystem,Path, and bufferedSource/Sinktypes. It covers JVM, Android and Native, with no file system in the browser. -
Okio is Square’s older, Stable library with
FileSystem.SYSTEM,Pathandokio.Buffer; DataStore itself depends on it, so it is already on your classpath if you use DataStore.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/export/GpxExporter.kt
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.writeString
fun writeGpx(file: Path, gpx: String) =
SystemFileSystem.sink(file).buffered().use { it.writeString(gpx) }
The directory comes from platform code; more libraries are on Essential Multiplatform Libraries.
Choosing SQLDelight or Room
| Criterion | SQLDelight 2.4 | Room 3 (3.0.3) |
|---|---|---|
Model |
SQL first: |
Kotlin first: annotated entities and DAOs generate SQL access |
Compile-time checking |
Every statement is verified against the schema by the plugin |
Queries in |
Migrations |
|
Auto-migrations and manual migrations, schema JSON exported |
Best when |
The team likes SQL, needs complex joins or views, or must share a schema with other systems |
You are migrating an existing Android Room codebase, or want Google-maintained Jetpack tooling |
A practical rule: migrating an Android app that already uses Room, choose Room 3, and the step is largely a package
rename plus the constructor boilerplate (see
Adopting KMP in Existing Android and iOS Apps). Starting
a new shared module with SQL-literate developers, SQLDelight’s build-time checking and .sq files are hard to beat.
Do not run both against the same file; if you must have both, give them separate database files.
Common Mistakes
| Mistake | Fix |
|---|---|
Blocking (non- |
Make every DAO function |
|
Add the compiler to |
Resetting user data in |
Use |
Creating two DataStore instances on one file |
Keep one instance per process through a DI |
Collecting a Flow in Swift without cancelling |
Start the loop in a |