If you are building a Kotlin Multiplatform app and want one shared local database that works identically on Android and iOS, SQLDelight is the simplest path. This beginner’s guide to SQLDelight for KMP focuses on the 2026 reality of offline sync: a single SQLite database defined once in Kotlin, compiled for both platforms, and accessed through type-safe coroutine APIs without touching Swift or Java database code.
The old approach usually meant writing a local database for Android with Room and a separate Core Data stack for iOS. That duplication is expensive, especially when offline sync logic must behave the same on both sides. SQLDelight changes the equation by generating drivers and tables from one shared schema, so your offline cache, query layer, and sync bookkeeping can all live inside your commonMain source set. By 2026, the tooling around SQLDelight has matured enough to make this a practical choice for production apps, not just a demo.
Why a Shared Local Database Wins in Kotlin Multiplatform
When you manage two separate local databases, you are also maintaining two data access layers, two migration systems, and two sets of offline sync rules. That usually means subtle differences in behavior appear when the app is offline. A shared local database prevents those differences because the schema, queries, and transaction logic are compiled from the same Kotlin code for every platform.
SQLDelight also brings compile-time verification. Your SQL statements are checked as you write them, and the generated Kotlin interfaces expose columns as strongly typed properties. This means you can catch missing tables or wrong column names before the app ever runs. For offline sync, this safety net is invaluable: when the network is unavailable and the user is relying on cached data, the last thing you want is a database exception that was never visible on the other platform.
Setting Up SQLDelight in a KMP Project
Adding SQLDelight to a Kotlin Multiplatform project starts with a single plugin. In your root build.gradle.kts, add the SQLDelight plugin with a version that supports your Kotlin Gradle plugin version. The 2026 releases are regularly updated, but the core setup remains stable. After applying the plugin, you define a package and a driver for each platform.
// root build.gradle.kts
plugins {
kotlin("multiplatform") version "2.1.0"
id("app.cash.sqldelight") version "2.2.0"
}
// shared build.gradle.kts
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.app.db")
}
}
}
androidMain.dependencies {
implementation("app.cash.sqldelight:android-driver:2.2.0")
}
iosMain.dependencies {
implementation("app.cash.sqldelight:native-driver:2.2.0")
}
For Android, the driver uses the built-in Android SQLite API. For iOS, SQLDelight uses the native SQLite library, and the generated Kotlin code talks directly to it. You never need to write a database layer in Swift. The driver only needs a single expect function to create the right instance for the current platform.
// commonMain
expect fun createDatabaseDriver(): SqlDriver
That one expect/actual pairing is enough to give you a shared database handle everywhere in your app.
Writing a Schema With One Migration Path
SQLDelight stores schema definitions in .sq files, usually inside src/commonMain/sqldelight. This is where you define your tables, indexes, and queries. Because the schema lives in common code, you only ever write one version history. Migrations are also defined in .sq files using a migration directory, and SQLDelight tracks the schema version for you.
Consider an offline-first chat app. You need a table for messages and a table for pending outbound messages. In SQLDelight, your schema might look like this:
-- Messages.sq
CREATE TABLE message (
id TEXT NOT NULL PRIMARY KEY,
conversation_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
is_pending INTEGER NOT NULL DEFAULT 0
);
selectMessagesForConversation:
SELECT * FROM message
WHERE conversation_id = ?
ORDER BY created_at DESC;
insertMessage:
INSERT INTO message(id, conversation_id, body, created_at, is_pending)
VALUES (?, ?, ?, ?, ?);
When you later add a column, you create a new migration file. SQLDelight will generate the code needed to upgrade the schema on both Android and iOS. You are not maintaining a separate migration for each platform. This is particularly useful for offline sync, because a migration bug usually shows up only after the user has been offline for a while and returns to a stale database version.
Building Offline Sync Without Native Code
Offline sync in a KMP app is often divided into three parts: a local cache, a sync worker, and an API client. The local cache is your SQLDelight database. The API client can be Ktor or something similar. The sync worker is a Kotlin class that pulls pending changes from the database, sends them to the server, and updates the local database with the server response.
Because SQLDelight generates coroutine-friendly APIs, you can write your sync logic as a Flow that observes every table change. When the user creates an entry while offline, you insert it with a pending flag. The sync worker watches for pending rows, attempts to push them to the server, and marks them as synced when successful. On the same path, the worker downloads new remote data and updates the local database.
class OfflineSyncRepository(
private val database: AppDatabase,
private val api: MessageApi
) {
suspend fun sync() {
val pending = database.messageQueries
.selectPendingMessages()
.executeAsList()
pending.forEach { message ->
val serverId = api.sendMessage(message)
database.transaction {
database.messageQueries.markSynced(
serverId = serverId,
localId = message.id
)
}
}
}
}
This pattern keeps the sync logic in one place. Your iOS app calls the same Kotlin sync function from Swift, and your Android app calls it from ViewModel code. No synchronization logic has to be rewritten for a second platform.
Handling Concurrency and Background Work
One area where shared databases often fail is concurrency. SQLite is thread-safe, but you still need to be intentional about transactions and connection usage. SQLDelight exposes a coroutine-based transaction API, and the generated table objects support Flow for reactive observation. That means you can use a single database connection and let Kotlin’s structured concurrency manage the rest.
For offline sync in 2026, the best practice is to keep transaction blocks small and avoid doing network calls inside a database transaction. Fetch data, process it, then open a transaction to write the result. If you need long-running background sync, use a WorkManager task on Android and a background task on iOS, but let both call the same Kotlin sync repository. The database itself does not care which platform initiated the operation.
Avoiding Common KMP Database Pitfalls
SQLDelight removes most of the cross-platform database headaches, but there are still a few traps worth knowing about.
- Do not use platform-specific SQL functions in shared queries. Keep your SQL portable. For date arithmetic and text functions, test the same query on both platforms.
- Be careful with numeric types. SQLite is dynamically typed, and SQLDelight will expect a specific Kotlin type. Store timestamps as
Longinstead ofStringto avoid parsing problems. - Remember that iOS simulators and devices can behave differently. Always run your database tests on both a simulator and a physical device before shipping an offline sync release.
- Do not enable schema verification only in debug builds. SQLDelight can verify the database schema at startup, which is useful to catch migration drift in production too.
- Respect suspend function boundaries. If you call blocking SQLDelight queries from a background thread on iOS, you can still freeze memory if you cross a Kotlin/Native thread boundary. Use
Dispatcherscorrectly in common code.
These pitfalls are less about SQLDelight itself and more about the realities of Kotlin Multiplatform in any database-heavy application. Keeping all queries in common code helps you catch them early.
Testing Offline Sync With Multiplatform Tests
Because your database schema is entirely in Kotlin, you can test sync behavior with the same JVM test code on Android and with the native test runner on iOS. SQLDelight provides a JdbcSqliteDriver for JVM tests and an in-memory driver for native tests. Your test creates the schema, inserts fake pending data, runs the sync repository against a mocked API, and verifies the database state changed correctly.
This kind of test is where a shared local database really pays off. Instead of writing a separate integration test suite for Android and iOS, you have one set of tests that exercises the real SQLDelight-generated code on every supported platform. The offline sync behavior is tested by the same Kotlin logic that runs in production.
Making the Switch in 2026
If you already have a native Android database and an iOS database, moving to SQLDelight might feel like a rewrite. But you do not need to migrate all at once. Start with a small feature, like storing user preferences or a simple table of synced records. Keep the existing native database for older data, then gradually move logic into common code. The goal is to reach a point where offline sync is defined in one place, tested in one place, and understood by every developer on the team.
SQLDelight for KMP is no longer an experiment. In 2026, it is a reliable way to get one shared local database without writing native database code, and it fits naturally into the Kotlin Multiplatform workflow for offline-first Android and iOS apps.
Conclusion
Building offline sync for Android and iOS does not require two separate database implementations. SQLDelight for KMP gives you one shared SQLite database with type-safe queries, a single migration history, and sync logic written entirely in Kotlin. By keeping the database layer in your common source set, you reduce duplication, avoid native database code, and make your app’s offline behavior consistently reliable across both platforms.
