Skip to content
Commitwise
Menu
Android Development

Room database

Store data on the device with Room, observe queries as Flow so the UI updates itself, make the local database the single source of truth for offline-first screens, and change the schema with migrations that never lose user data.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The user writes a long note on the subway, loses signal, and the app throws it away. Another opens the app on a plane and sees an empty screen, although they looked at the same orders an hour ago. An update ships, and everyone’s saved drafts disappear. All three are local storage problems: the data wasn’t kept on the device, wasn’t shown from the device, or didn’t survive a schema change.

Room is Android’s standard library for a local SQLite database. This tutorial covers the basics, then the patterns that make apps work offline, and the migrations that keep data safe across updates.

New to some of these? Open what you need, or skip ahead.

Basics:What is a database?

A database stores data in a structured way so it can be searched and updated quickly. SQLite, built into every Android phone, keeps data in tables: rows and columns, like a spreadsheet. You ask for data with SQL, a query language:

SELECT * FROM notes WHERE archived = 0 ORDER BY updated_at DESC
Basics:What is a Flow?

A Flow is a Kotlin stream of values that arrive over time. A screen collects a Flow and updates every time a new value comes in, for example a new list of notes after one was added. The Coroutines and Flow tutorial covers it in detail.

Part Not started

Entities, DAOs and the database

A library has shelves (the tables), a catalog card format for each book (the entity), and a librarian who knows how to find and put away books (the DAO). You never walk into the stacks yourself; you ask the librarian.

Room uses three pieces:

  • an entity: a data class that describes one table, one property per column;
  • a DAO (data access object): an interface with the queries you need, written in SQL;
  • the database class that lists the entities and gives you the DAOs.

Room checks your SQL while compiling: a misspelled column is a build error, not a crash on a user’s phone.

Room generates the DAO implementations (via KSP) and validates every query against the schema at compile time. suspend DAO functions run on Room’s query executor; Flow-returning queries register an invalidation observer on the tables they read and re-run when those tables change. Multi-statement work goes in @Transaction (or withTransaction). Since Room 2.7 the same API also runs in Kotlin Multiplatform with the bundled SQLite driver.

@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey val id: String,                      // a UUID, created on the device
    val title: String,
    val body: String,
    @ColumnInfo(name = "updated_at") val updatedAt: Long,
    val archived: Boolean = false,
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes WHERE archived = 0 ORDER BY updated_at DESC")
    fun observeActive(): Flow<List<NoteEntity>>      // re-emits whenever notes change

    @Query("SELECT * FROM notes WHERE id = :id")
    suspend fun byId(id: String): NoteEntity?

    @Upsert
    suspend fun save(note: NoteEntity)               // insert or update

    @Query("UPDATE notes SET archived = 1, updated_at = :now WHERE id = :id")
    suspend fun archive(id: String, now: Long)
}

@Database(entities = [NoteEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun notes(): NoteDao
}

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()   // one per app
  • Flow<List<NoteEntity>>: the screen collects it once, and Room sends a new list every time a note is saved or archived. You never “refresh” by hand.
  • suspend for writes and one-off reads: Room runs them off the main thread. Room refuses to run a blocking query on the main thread at all, because it would freeze the UI.
  • @Upsert inserts a new row or updates the existing one with the same primary key.
  • One database instance for the whole app (create it in your DI setup), because opening it is expensive.
Spot the bugThe new note doesn't appear

Tap the line with the bug.

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes WHERE archived = 0 ORDER BY updated_at DESC")
    suspend fun active(): List<NoteEntity>
}

class NotesViewModel(private val dao: NoteDao) : ViewModel() {
    val notes = MutableStateFlow<List<NoteEntity>>(emptyList())
    init { viewModelScope.launch { notes.value = dao.active() } }

    fun add(title: String) = viewModelScope.launch {
        dao.save(NoteEntity(UUID.randomUUID().toString(), title, "", System.currentTimeMillis()))
    }
}

Predict the output

A screen collects dao.observeActive(). The user archives one of 10 notes. How many lists does the Flow emit in total, from opening the screen?

Exerciseeasy

Search notes

Add a DAO query that returns active notes whose title or body contains a search text, newest first, as a Flow. Then, in the ViewModel, connect it to a query: MutableStateFlow<String> so the results update as the user types.

›

SQL’s LIKE '%' || :text || '%' matches text anywhere in a column. In the ViewModel, flatMapLatest switches to a new database Flow every time the query changes.

SolutionTry it yourself first
@Query("""
    SELECT * FROM notes
    WHERE archived = 0 AND (title LIKE '%' || :text || '%' OR body LIKE '%' || :text || '%')
    ORDER BY updated_at DESC
""")
fun search(text: String): Flow<List<NoteEntity>>

class NotesViewModel(private val dao: NoteDao) : ViewModel() {
    val query = MutableStateFlow("")

    @OptIn(ExperimentalCoroutinesApi::class)
    val results: StateFlow<List<NoteEntity>> = query
        .debounce(200)                               // wait until typing pauses
        .flatMapLatest { text -> dao.search(text) }  // cancel the previous query's Flow
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

:text is a bound parameter, so user input can never change the SQL itself (no SQL injection). For large amounts of text, Room’s full-text search (@Fts4 entities and MATCH) is much faster than LIKE.

Things the generated code won’t do for you

  • Invalidation is per table: a Flow re-runs its query on any write to a table it reads, even when its own rows didn’t change. Add distinctUntilChanged() before expensive downstream work.
  • Relations: @Relation and multimap return types (Map<Order, List<Item>>) run several queries; mark such DAO methods @Transaction so they read a consistent snapshot.
  • Large lists: return PagingSource<Int, NoteEntity> and use Paging 3 instead of loading thousands of rows into memory.
  • Testing: Room.inMemoryDatabaseBuilder with the real DAO in a Robolectric or instrumented test checks your SQL against real SQLite; mocking DAOs tests nothing about the queries.
  • @Entity, @Dao, @Database; queries validated at compile time (KSP). One database instance per process.
  • suspend writes/one-shot reads; Flow queries re-emit on table invalidation (per table, so distinctUntilChanged).
  • @Transaction / withTransaction for multi-statement work and relations; Paging 3 PagingSource for large lists.
  • Test DAOs against an in-memory database, not mocks. Room 2.7+ runs in KMP.

Check this part

  1. Why does a DAO query return Flow<List<NoteEntity>> instead of List<NoteEntity>?

  2. When does Room report a misspelled column in a @Query?

  3. A Flow query on orders WHERE id = 5 re-emits after order 7 changes. Why?

An @Entity class is a table, a @Dao interface holds the queries (checked at compile time), and the @Database class ties them together. Write with suspend functions, read with Flow, and the UI updates automatically when the data changes.

Part Not started

The database as the single source of truth

When the screen shows data straight from the network, it’s empty until the request finishes, and empty again with no connection. Offline-first apps turn this around:

  1. The screen only ever reads from the database, through a Flow.
  2. In the background, the repository asks the network for fresh data and writes it into the database.
  3. Room notices the change and the screen updates on its own.

Opening the app shows the last known data instantly, works on a plane, and a slow network only delays the update, not the screen.

The repository exposes Flows from Room and a refresh() that fetches and upserts in a transaction; the network never talks to the UI directly. Writes go to Room first (optimistic, with a sync status column or an outbox table) and are pushed by WorkManager with a network constraint, which survives process death. Conflicts are resolved by policy (last-write-wins with server timestamps, field-level merges, or server authority) as covered in Mobile: offline-first and sync.

class OrdersRepository(
    private val dao: OrderDao,
    private val api: ShopApi,
    private val db: AppDatabase,
) {
    fun observeOrders(): Flow<List<Order>> =
        dao.observeAll().map { rows -> rows.map { it.toDomain() } }

    suspend fun refresh(): Result<Unit> = apiCall {
        val fresh = api.orders()
        db.withTransaction {                               // replace atomically: no half-updated list
            dao.deleteAllSynced()
            dao.upsertAll(fresh.map { it.toEntity() })
        }
    }
}

class OrdersViewModel(private val repository: OrdersRepository) : ViewModel() {
    val orders = repository.observeOrders()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    private val _refreshError = MutableStateFlow<AppError?>(null)
    val refreshError: StateFlow<AppError?> = _refreshError

    init { refresh() }

    fun refresh() = viewModelScope.launch {
        _refreshError.value = repository.refresh().exceptionOrNull()?.toAppError()
    }
}
  • The list on screen comes only from observeOrders(). A failed refresh doesn’t empty it; it shows a small “Couldn’t update” message over the cached data.
  • withTransaction makes the delete and the insert one step: the screen never sees an empty list in between.
  • deleteAllSynced keeps orders the user created offline that haven’t been sent yet.

Production puzzles

Predict the output

The refresh above runs deleteAll() and then upsertAll() without withTransaction. The screen collects observeOrders(). What can the user see during a refresh?

Sync design checklist

  • Local ids: create UUIDs on the device so offline-created rows have stable ids before the server sees them.
  • Pending changes: a sync_state column or an outbox table; WorkManager uploads with backoff and a network constraint.
  • Freshness: store lastRefreshedAt; refresh on screen open only when stale, and on pull-to-refresh always.
  • Deletes: soft-delete locally until the server confirms; tombstones from the server for rows deleted elsewhere.
  • UI reads Room Flows only; network refreshes write to Room in withTransaction (no flicker, consistent snapshots).
  • Local writes first, with sync state or an outbox; WorkManager pushes with network constraints and backoff.
  • Device-generated UUIDs, staleness timestamps, soft deletes and tombstones; explicit conflict policy.

Check this part

  1. In an offline-first app, where does the screen read its data from?

  2. The refresh fails because the phone is offline. What should the screen show?

Offline-first screens read only from Room; the network only refreshes the database. The UI shows cached data instantly, works without a connection, and local edits are queued and synced later.

Part Not started

Migrations

Users don’t reinstall your app; they update it, and their database stays. If version 2 of the app expects a color column in notes and the phone’s database from version 1 doesn’t have one, the app crashes on start. A migration is the instruction that changes the old database into the new shape, without losing what’s in it.

Room compares the on-disk schema version with @Database(version) and runs the migration path between them; a missing path throws at open time, unless a destructive fallback is configured. With exportSchema = true and a schema directory, Room writes the schema JSON per version, which enables @AutoMigration (with specs for renames and deletions) and MigrationTestHelper tests that open each historical version, migrate, and validate.

@Database(
    entities = [NoteEntity::class],
    version = 3,
    exportSchema = true,
    autoMigrations = [
        AutoMigration(from = 1, to = 2),                             // added the color column
        AutoMigration(from = 2, to = 3, spec = RenameBody::class),   // renamed body → content
    ],
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun notes(): NoteDao

    @RenameColumn(tableName = "notes", fromColumnName = "body", toColumnName = "content")
    class RenameBody : AutoMigrationSpec
}

// A change auto-migration can't express: split "title" into title + subtitle
val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE notes ADD COLUMN subtitle TEXT NOT NULL DEFAULT ''")
        db.execSQL("""
            UPDATE notes SET subtitle = trim(substr(title, instr(title, ':') + 1)),
                             title = trim(substr(title, 1, instr(title, ':') - 1))
            WHERE instr(title, ':') > 0
        """)
    }
}
  • Bump version for every change to an entity, even adding one column.
  • Auto-migrations handle simple changes (new columns, new tables); renames and deletions need a small spec so Room doesn’t drop and recreate the column.
  • Manual migrations are plain SQL for anything else.
  • Export the schema (room { schemaDirectory("$projectDir/schemas") } in Gradle) and commit the JSON files: they’re the history Room needs.
Spot the bugEveryone's drafts are gone

Tap the line with the bug.

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .fallbackToDestructiveMigration(dropAllTables = true)
    .build()

@Database(entities = [NoteEntity::class, DraftEntity::class], version = 5)
abstract class AppDatabase : RoomDatabase()

Testing migrations

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java,
    )

    @Test
    fun migrate3To4_splitsTitles() {
        helper.createDatabase("test.db", 3).use { db ->
            db.execSQL("INSERT INTO notes (id, title, content, updated_at, archived, color) " +
                "VALUES ('n1', 'Trip: packing list', '', 0, 0, 0)")
        }
        val db = helper.runMigrationsAndValidate("test.db", 4, true, MIGRATION_3_4)
        db.query("SELECT title, subtitle FROM notes WHERE id = 'n1'").use { c ->
            c.moveToFirst()
            assertEquals("Trip", c.getString(0))
            assertEquals("packing list", c.getString(1))
        }
    }

    @Test
    fun migrateAll_fromVersion1() {
        helper.createDatabase("all.db", 1).close()
        Room.databaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java, "all.db")
            .addMigrations(MIGRATION_3_4)
            .build()
            .apply { openHelper.writableDatabase.close() }   // opens and runs every migration
    }
}

Users skip versions: someone on version 1 may update straight to version 7. Test the full chain from the oldest version still installed (check your analytics), not only the last step.

  • Every entity change bumps the version; missing paths throw at open (or wipe data with destructive fallback).
  • exportSchema + committed schema JSON → @AutoMigration (specs for rename/delete) and MigrationTestHelper.
  • Manual Migration for data transforms; test single steps with data and the full chain from the oldest installed version.
  • Destructive fallback only for re-downloadable caches, ideally in a separate database.

Check this part

  1. You add a column to an entity. What else must change?

  2. What does fallbackToDestructiveMigration do when a migration is missing?

  3. Why test the full migration chain, not just the latest step?

Every schema change bumps the database version and needs a migration: auto-migrations for simple changes, a Migration with SQL for the rest. Export schemas and test migrations; never ship fallbackToDestructiveMigration for data users created.

Key takeaways

  • An entity is a table, a DAO holds the queries, and Room checks the SQL when you build.
  • Read with Flow so screens update by themselves; write with suspend functions.
  • Offline-first: the screen reads only from the database; the network refreshes it.
  • Every schema change needs a version bump and a migration; never wipe user data.
  • Table-level invalidation, @Transaction for consistency, Paging for large sets.
  • Repository exposes Room Flows; refresh in withTransaction; local writes synced by WorkManager.
  • Exported schemas, auto-migrations with specs, MigrationTestHelper for each step and the whole chain.
  • Destructive fallback only for caches, kept in their own database.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.