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

android-driver: AndroidSqliteDriver(Schema, context, name)

Uses the framework SQLite and SupportSQLiteOpenHelper; creates and migrates the schema automatically.

iOS, macOS and other Apple targets

native-driver: NativeSqliteDriver(Schema, name)

Uses the system SQLite; stored in the app’s Application Support directory.

JVM desktop

sqlite-driver: JdbcSqliteDriver(url, schema = Schema)

JDBC with a bundled SQLite; you choose the file path.

Kotlin/JS and Wasm

web-worker-driver: WebWorkerDriver(Worker(…​))

Asynchronous only; needs generateAsync.set(true) and an npm SQLite worker, which changes every query to await… style. Treat web persistence as a separate decision.

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

AndroidSQLiteDriver

androidx.sqlite:sqlite-framework

Android

The framework SQLite built into the OS, so its version depends on the Android release.

NativeSQLiteDriver

androidx.sqlite:sqlite-framework

iOS, macOS and other Kotlin/Native targets

The SQLite shipped by the operating system.

BundledSQLiteDriver

androidx.sqlite:sqlite-bundled

Android, iOS and Apple, JVM desktop, Linux and other native targets

A SQLite compiled into your app; identical everywhere. Recommended default.

WebWorkerSQLiteDriver

androidx.sqlite:sqlite-web

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 buffered Source / Sink types. It covers JVM, Android and Native, with no file system in the browser.

  • Okio is Square’s older, Stable library with FileSystem.SYSTEM, Path and okio.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: .sq files generate Kotlin

Kotlin first: annotated entities and DAOs generate SQL access

Compile-time checking

Every statement is verified against the schema by the plugin

Queries in @Query strings are verified by KSP against the entities

Migrations

.sqm files, verified against snapshots

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-suspend, non-Flow) DAO functions in common code

Make every DAO function suspend or Flow.

ksp(…​) or kspCommonMainMetadata for Room

Add the compiler to kspAndroid, kspIosArm64, kspIosSimulatorArm64 and each other target.

Resetting user data in INSERT OR REPLACE on every refresh

Use ON CONFLICT DO UPDATE on server-owned columns only (see Trail.sq).

Creating two DataStore instances on one file

Keep one instance per process through a DI single.

Collecting a Flow in Swift without cancelling

Start the loop in a .task { } modifier so it is cancelled with the view.