In the Ktor tutorial, we loaded data from the internet. But what happens when the user has no internet? The app shows nothing.

You need a local database — and in KMP, that means SQLDelight.

SQLDelight is the opposite of Room. Room generates SQL from Kotlin. SQLDelight generates Kotlin from SQL. You write real SQL queries, and SQLDelight generates type-safe Kotlin code. It works on Android, iOS, Desktop, and Web — all from the same .sq files.

SQLDelight vs Room — Which for KMP?

SQLDelightRoom
ApproachSQL first → generates KotlinKotlin first → generates SQL
KMP supportFull (designed for it)Partial (added later)
Query languageYou write real SQLAnnotations + DAO methods
Type safetyGenerated from SQLGenerated from annotations
Migration.sqm files with SQLJava/Kotlin migration classes
Best forKMP projects, SQL loversAndroid-only, Kotlin lovers

If you are building KMP, SQLDelight is the standard choice. Room’s KMP support is newer and less mature.

Setup

Dependencies

# gradle/libs.versions.toml
[versions]
sqldelight = "2.0.2"

[libraries]
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }

[plugins]
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }

Gradle Configuration

// shared/build.gradle.kts
plugins {
    alias(libs.plugins.sqldelight)
}

// Configure the database
sqldelight {
    databases {
        create("AppDatabase") {
            packageName.set("com.kemalcodes.db")
        }
    }
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.sqldelight.coroutines)
        }
        androidMain.dependencies {
            implementation(libs.sqldelight.android.driver)
        }
        iosMain.dependencies {
            implementation(libs.sqldelight.native.driver)
        }
    }
}

The sqldelight block tells the plugin to generate a database class called AppDatabase in the package com.kemalcodes.db.

Create the Driver (expect/actual)

Each platform needs a different SQLite driver:

// shared/src/commonMain/kotlin/database/DriverFactory.kt
// Note: expect/actual classes are in beta — constructor signatures
// can differ between expect and actual declarations
expect class DriverFactory {
    fun createDriver(): SqlDriver
}
// shared/src/androidMain/kotlin/database/DriverFactory.android.kt
import android.content.Context
import app.cash.sqldelight.driver.android.AndroidSqliteDriver

actual class DriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver {
        return AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
    }
}
// shared/src/iosMain/kotlin/database/DriverFactory.ios.kt
import app.cash.sqldelight.driver.native.NativeSqliteDriver

actual class DriverFactory {
    actual fun createDriver(): SqlDriver {
        return NativeSqliteDriver(AppDatabase.Schema, "app.db")
    }
}

Step 1: Define Your Tables with .sq Files

This is where SQLDelight is fundamentally different from Room. Instead of Kotlin classes, you write SQL files.

Create shared/src/commonMain/sqldelight/com/kemalcodes/db/Note.sq:

-- Note.sq
-- This file defines the 'notes' table and all queries for it.
-- SQLDelight generates Kotlin code from these SQL statements.

-- TABLE DEFINITION
CREATE TABLE notes (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL DEFAULT '',
    is_completed INTEGER NOT NULL DEFAULT 0,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

-- CREATE INDEX for faster searches
CREATE INDEX notes_title ON notes(title);

-- QUERIES
-- Each named query becomes a Kotlin function

-- Get all notes, newest first
selectAll:
SELECT * FROM notes ORDER BY created_at DESC;

-- Get one note by ID
selectById:
SELECT * FROM notes WHERE id = ?;

-- Search notes by title (case-insensitive)
searchByTitle:
SELECT * FROM notes
WHERE title LIKE '%' || ? || '%'
ORDER BY created_at DESC;

-- Get only incomplete notes
selectActive:
SELECT * FROM notes
WHERE is_completed = 0
ORDER BY created_at DESC;

-- Get only completed notes
selectCompleted:
SELECT * FROM notes
WHERE is_completed = 1
ORDER BY created_at DESC;

-- Count all notes
countAll:
SELECT COUNT(*) FROM notes;

-- Count completed notes
countCompleted:
SELECT COUNT(*) FROM notes WHERE is_completed = 1;

-- INSERT a new note
insert:
INSERT INTO notes (title, content, is_completed, created_at, updated_at)
VALUES (?, ?, 0, ?, ?);

-- UPDATE a note's title and content
updateNote:
UPDATE notes
SET title = ?, content = ?, updated_at = ?
WHERE id = ?;

-- Toggle completion status
toggleCompleted:
UPDATE notes
SET is_completed = CASE WHEN is_completed = 0 THEN 1 ELSE 0 END,
    updated_at = ?
WHERE id = ?;

-- DELETE a note
deleteById:
DELETE FROM notes WHERE id = ?;

-- DELETE all completed notes
deleteCompleted:
DELETE FROM notes WHERE is_completed = 1;

This is real SQL. Every named block (like selectAll:) becomes a Kotlin function in the generated code. The ? placeholders become function parameters.

What SQLDelight Generates

From the .sq file above, SQLDelight generates:

// Auto-generated — you never write this
interface NoteQueries : Transacter {
    fun selectAll(): Query<Notes>
    fun selectById(id: Long): Query<Notes>
    fun searchByTitle(title: String): Query<Notes>
    fun selectActive(): Query<Notes>
    fun selectCompleted(): Query<Notes>
    fun countAll(): Query<Long>
    fun countCompleted(): Query<Long>
    fun insert(title: String, content: String, created_at: Long, updated_at: Long)
    fun updateNote(title: String, content: String, updated_at: Long, id: Long)
    fun toggleCompleted(updated_at: Long, id: Long)
    fun deleteById(id: Long)
    fun deleteCompleted()
}

Type-safe. Compile-time checked. If your SQL has a typo, the project won’t compile — not a runtime crash.

Step 2: Create the Repository

Wrap the generated queries in a repository that returns Flow:

// shared/src/commonMain/kotlin/repository/NoteRepository.kt

import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOneOrNull
import com.kemalcodes.db.AppDatabase
import com.kemalcodes.db.Notes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow

class NoteRepository(database: AppDatabase) {

    private val queries = database.noteQueries

    // Get all notes as a Flow — UI updates automatically when data changes
    fun getAllNotes(): Flow<List<Notes>> {
        return queries.selectAll()
            .asFlow()
            .mapToList(Dispatchers.IO)
    }

    // Get one note by ID
    fun getNoteById(id: Long): Flow<Notes?> {
        return queries.selectById(id)
            .asFlow()
            .mapToOneOrNull(Dispatchers.IO)
    }

    // Search notes
    fun searchNotes(query: String): Flow<List<Notes>> {
        return queries.searchByTitle(query)
            .asFlow()
            .mapToList(Dispatchers.IO)
    }

    // Get active (incomplete) notes
    fun getActiveNotes(): Flow<List<Notes>> {
        return queries.selectActive()
            .asFlow()
            .mapToList(Dispatchers.IO)
    }

    // Get completed notes
    fun getCompletedNotes(): Flow<List<Notes>> {
        return queries.selectCompleted()
            .asFlow()
            .mapToList(Dispatchers.IO)
    }

    // INSERT
    fun addNote(title: String, content: String = "") {
        val now = currentTimeMillis()
        queries.insert(
            title = title,
            content = content,
            created_at = now,
            updated_at = now
        )
    }

    // UPDATE
    fun updateNote(id: Long, title: String, content: String) {
        queries.updateNote(
            title = title,
            content = content,
            updated_at = currentTimeMillis(),
            id = id
        )
    }

    // TOGGLE completion
    fun toggleNote(id: Long) {
        queries.toggleCompleted(
            updated_at = currentTimeMillis(),
            id = id
        )
    }

    // DELETE one
    fun deleteNote(id: Long) {
        queries.deleteById(id)
    }

    // DELETE all completed
    fun deleteCompletedNotes() {
        queries.deleteCompleted()
    }

    // Transactions — multiple operations as one atomic unit
    fun addMultipleNotes(titles: List<String>) {
        queries.transaction {
            val now = currentTimeMillis()
            titles.forEach { title ->
                queries.insert(
                    title = title,
                    content = "",
                    created_at = now,
                    updated_at = now
                )
            }
        }
    }
}

// Platform-specific time function
expect fun currentTimeMillis(): Long

How Flow Works with SQLDelight

Database changes (insert/update/delete)
SQLDelight detects the change
.asFlow() emits new query result
.mapToList() converts to List<Notes>
ViewModel collects it
UI updates automatically

This is the same reactive pattern as Room + Flow. No manual refresh needed.

Step 3: Use in ViewModel

// shared/src/commonMain/kotlin/viewmodel/NotesViewModel.kt

class NotesViewModel(private val repository: NoteRepository) {

    private val _state = MutableStateFlow(NotesScreenState())
    val state: StateFlow<NotesScreenState> = _state.asStateFlow()

    private var searchJob: Job? = null

    init {
        observeNotes("")
    }

    fun addNote(title: String, content: String = "") {
        repository.addNote(title, content)
        // No manual refresh — Flow handles it
    }

    fun toggleNote(id: Long) {
        repository.toggleNote(id)
    }

    fun deleteNote(id: Long) {
        repository.deleteNote(id)
    }

    fun onSearchQueryChange(query: String) {
        _state.update { it.copy(searchQuery = query) }
        observeNotes(query)
    }

    fun deleteCompleted() {
        repository.deleteCompletedNotes()
    }

    private fun observeNotes(query: String) {
        searchJob?.cancel()
        searchJob = viewModelScope.launch {
            val flow = if (query.isEmpty()) {
                repository.getAllNotes()
            } else {
                repository.searchNotes(query)
            }
            flow.collect { notes ->
                _state.update { it.copy(notes = notes) }
            }
        }
    }
}

data class NotesScreenState(
    val notes: List<Notes> = emptyList(),
    val searchQuery: String = ""
)

This ViewModel works on both Android and iOS. The repository, database, and queries are all shared code.

Migrations — Evolving Your Schema

Your app is in production. Users have data. Now you need to add a new column. You can’t just change the CREATE TABLE statement — that would destroy existing data.

SQLDelight uses migration files (.sqm) to upgrade existing databases.

How Versioning Works

Version 1: Initial schema (from Note.sq)
Version 2: Add 'priority' column (from 1.sqm)
Version 3: Add 'category' column (from 2.sqm)

The .sq file always describes the latest schema. Migration files describe how to get from one version to the next.

Creating a Migration

Step 1: Update your .sq file with the new column:

-- Note.sq (updated — version 3)
CREATE TABLE notes (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL DEFAULT '',
    is_completed INTEGER NOT NULL DEFAULT 0,
    priority INTEGER NOT NULL DEFAULT 0,    -- NEW in version 2
    category TEXT NOT NULL DEFAULT '',       -- NEW in version 3
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

Step 2: Create migration files:

shared/src/commonMain/sqldelight/migrations/
├── 1.sqm    ← Migrates from version 1 → 2
└── 2.sqm    ← Migrates from version 2 → 3
-- 1.sqm (version 1 → 2)
-- Adds the priority column
ALTER TABLE notes ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
-- 2.sqm (version 2 → 3)
-- Adds the category column
ALTER TABLE notes ADD COLUMN category TEXT NOT NULL DEFAULT '';

Step 3: Update the database version in your driver:

// The schema version is automatically tracked by SQLDelight
// AppDatabase.Schema.version returns the current version
// Migrations run automatically when the database opens

Migration with Data Transformation

Sometimes you need to transform data during migration. Use AfterVersion:

AppDatabase.Schema.migrate(
    driver = driver,
    oldVersion = 1,
    newVersion = AppDatabase.Schema.version,
    AfterVersion(2) { driver ->
        // After migrating to version 3, set default category
        // for all existing notes based on title keywords
        driver.execute(null, """
            UPDATE notes SET category = 'work'
            WHERE title LIKE '%meeting%' OR title LIKE '%project%'
        """.trimIndent(), 0)
        driver.execute(null, """
            UPDATE notes SET category = 'personal'
            WHERE category = ''
        """.trimIndent(), 0)
    }
)

Verifying Migrations

SQLDelight can verify your migrations produce the correct schema:

  1. Place a database file from version 1 at shared/src/commonMain/sqldelight/1.db
  2. Run ./gradlew verifySqlDelightMigration
  3. SQLDelight applies all migrations and checks the result matches your .sq file

This catches migration bugs before they reach production.

Migration Best Practices

PracticeWhy
Always use DEFAULT for new columnsExisting rows need a value
Never rename columnsSQLite doesn’t support ALTER TABLE RENAME COLUMN in all versions
Test migrations on real dataUse the .db verification method
Keep migrations smallOne change per .sqm file
Never delete migration filesUsers might upgrade from any old version
Don’t wrap in transactionsSQLDelight handles transactions automatically

Transactions

For multiple operations that must succeed or fail together:

// All or nothing — if any insert fails, none are saved
queries.transaction {
    queries.insert("Buy groceries", "", now, now)
    queries.insert("Clean house", "", now, now)
    queries.insert("Call dentist", "", now, now)
}

// With rollback handling
queries.transaction {
    queries.insert("Important task", "", now, now)

    // Check a condition
    val count = queries.countAll().executeAsOne()
    if (count > 100) {
        rollback()  // Cancel the transaction — nothing is saved
    }
}

Full CRUD Example

Here is every operation in one place:

class NotesCrudExample(private val db: AppDatabase) {

    private val queries = db.noteQueries
    private val now get() = Clock.System.now().toEpochMilliseconds()

    // CREATE
    fun create(title: String, content: String = "") {
        queries.insert(title, content, now, now)
    }

    // READ — single item
    fun getById(id: Long): Notes? {
        return queries.selectById(id).executeAsOneOrNull()
    }

    // READ — all items as Flow
    fun getAll(): Flow<List<Notes>> {
        return queries.selectAll().asFlow().mapToList(Dispatchers.IO)
    }

    // UPDATE
    fun update(id: Long, title: String, content: String) {
        queries.updateNote(title, content, now, id)
    }

    // DELETE — single
    fun delete(id: Long) {
        queries.deleteById(id)
    }

    // DELETE — batch
    fun deleteCompleted() {
        queries.deleteCompleted()
    }

    // TOGGLE
    fun toggle(id: Long) {
        queries.toggleCompleted(now, id)
    }

    // BATCH INSERT (transaction)
    fun createMultiple(titles: List<String>) {
        queries.transaction {
            titles.forEach { title ->
                queries.insert(title, "", now, now)
            }
        }
    }

    // COUNT
    fun totalCount(): Long {
        return queries.countAll().executeAsOne()
    }

    // SEARCH
    fun search(query: String): Flow<List<Notes>> {
        return queries.searchByTitle(query).asFlow().mapToList(Dispatchers.IO)
    }
}

Common Mistakes

Mistake 1: Forgetting the .sq File Location

# WRONG — won't be found by SQLDelight
shared/src/commonMain/kotlin/Note.sq

# RIGHT — must be in the sqldelight folder
shared/src/commonMain/sqldelight/com/kemalcodes/db/Note.sq

The path after sqldelight/ must match the packageName in your Gradle config.

Mistake 2: Not Using Flow for UI

// BAD — only gets data once, doesn't update
val notes = queries.selectAll().executeAsList()

// GOOD — updates automatically when data changes
val notes = queries.selectAll().asFlow().mapToList(Dispatchers.IO)

Mistake 3: Forgetting Dispatchers.IO

// BAD — runs on main thread, might cause ANR
queries.selectAll().asFlow().mapToList()

// GOOD — runs database queries on IO thread
queries.selectAll().asFlow().mapToList(Dispatchers.IO)

Mistake 4: Wrong Migration File Names

# WRONG — migration won't be found
migrations/v1_to_v2.sqm
migrations/migration_1.sqm

# RIGHT — must be just the version number
migrations/1.sqm    ← Migrates FROM version 1
migrations/2.sqm    ← Migrates FROM version 2

Mistake 5: Changing .sq Without Migration

-- If you add a column to the .sq file:
CREATE TABLE notes (
    ...
    new_column TEXT NOT NULL  -- Added this
);

-- You MUST also create a migration file:
-- migrations/N.sqm
ALTER TABLE notes ADD COLUMN new_column TEXT NOT NULL DEFAULT '';

Without the migration file, existing users lose all their data.

Quick Reference

ConceptSQLDelightRoom Equivalent
Table definition.sq file with CREATE TABLE@Entity data class
QueriesNamed queries in .sq@Query in @Dao interface
Auto-generated codeKotlin from SQLSQL from Kotlin
Reactive queries.asFlow().mapToList()Flow<List<T>> return type
Migrations.sqm files with SQLMigration classes
Transactionsqueries.transaction { }@Transaction annotation
Platform driverexpect/actual DriverFactoryBuilt-in (Android only)

Source Code

The KMP tutorial project is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about DataStore — multiplatform key-value storage for settings and preferences. Unlike SQLDelight (which stores structured data), DataStore is for simple settings like “dark mode on/off” and “last selected tab.”

See you there.