Pocket Studio Academy
HomePart 44.5

Room — a real database

Full course14 min read·4 questions

Everything you have stored so far vanishes when the app closes. Room writes it to the phone properly, checks your queries at build time, and pushes fresh data to the screen the instant anything changes.

Where has everything gone?

Every piece of state you have written so far lives in memory. Memory is emptied when the app closes. A notes app that forgets your notes when you swipe it away is not a notes app.

You need : data written to the phone's storage, where it survives the app closing, the phone restarting, and three weeks of not being opened.

Android's answer is , and it sits on top of a database that is already on every phone in the world.

Think of it like this

Think about a filing cabinet with a very particular clerk in front of it.

The cabinet is — a real database, already built into every Android phone, extremely fast, and completely unforgiving about spelling. Ask it for the "titel" column and it will tell you, at the worst possible moment, that no such column exists.

The clerk is Room. Before the cabinet ever opens, the clerk reads every request you have written and checks it against the cabinet's actual layout. Get a column name wrong and the clerk stops you at the desk — while you are still building the app, not while a user is holding it.

The clerk also does the tedious part: turning rows into Kotlin objects and back. That is the code you never have to write.

Three pieces and nothing else

Room needs three things from you.

PieceAnnotationWhat it is
[[entityEntity]]@EntityA class describing one table.
[[daoDAO]]@DaoAn interface listing the operations you want.
Database@DatabaseThe thing that ties them together and opens the file.

You write the what. Room's code generator writes the how at build time.

1. The entity: a table, written as a class

data/Note.ktkotlin
1@Entity(tableName = "notes")
2data class Note(
3  @PrimaryKey(autoGenerate = true)
4  val id: Long = 0L,
5  val title: String = "",
6  val body: String = "",
7  val updatedAt: Long = 0L
8)

That is the real Note from Pocket Notes, and it produces this table:

idtitlebodyupdatedAt
1ShoppingOats, tea, the good bread1755277112000
2IdeasRing dial, stats, a chime1755190000000

One class, one table. One property, one column.

@PrimaryKey(autoGenerate = true) marks the column that must be unique. With autoGenerate, saving a Note whose id is 0 means "you pick one" — so id = 0 becomes the app's word for brand new, which is why the editor route in Lesson 4.4 was "editor/0".

updatedAt is a rather than a date, because SQLite stores numbers, text and blobs — nothing else. Milliseconds since 1970 is a number, so it needs no translation. Anything SQLite does not recognise needs a , and the error you get without one is in the Error Doctor below.

2. The DAO: what you want done

3. The database

data/NotesDatabase.ktkotlin
1@Database(
2  entities = [Note::class],
3  version = 1,
4  exportSchema = false
5)
6abstract class NotesDatabase : RoomDatabase() {
7  abstract fun noteDao(): NoteDao
8}

Abstract, because Room generates the real subclass. version matters enormously and is covered below.

Opening a database is expensive, and opening the same file twice can corrupt it, so an app opens it once and shares it — a :

data/NotesDatabase.ktkotlin
1companion object {
2
3  @Volatile
4  private var instance: NotesDatabase? = null
5
6  fun get(context: Context): NotesDatabase {
7    return instance ?: synchronized(this) {
8      val created = Room.databaseBuilder(
9        context.applicationContext,
10        NotesDatabase::class.java,
11        "pocket-notes.db"
12      ).build()
13      instance = created
14      created
15    }
16  }
17}

applicationContext is deliberate: holding an Activity here would leak a screen for the lifetime of the app. synchronized means two coroutines arriving at the same instant still produce one database.

Wiring up KSP

Room's generator runs through , declared as a Gradle plugin:

app/build.gradle.ktskts
1plugins {
2  alias(libs.plugins.android.application)
3  alias(libs.plugins.kotlin.android)
4  alias(libs.plugins.kotlin.compose)
5  alias(libs.plugins.ksp)
6}
7
8dependencies {
9  implementation(libs.androidx.room.runtime)
10  implementation(libs.androidx.room.ktx)
11  ksp(libs.androidx.room.compiler)
12}

Note the third line in dependencies: ksp(...), not implementation(...). The compiler is a build-time tool. Adding it as an implementation dependency would ship the entire code generator inside your APK and generate nothing.

gradle/libs.versions.tomltoml
1[versions]
2room = "2.6.1"
3ksp = "2.1.0-1.0.29"

The KSP version has two halves: the Kotlin version it matches (2.1.0) and its own release number. They must line up with your Kotlin version exactly — a mismatch is one of the errors below.

Note

KSP replaced an older tool called kapt. KSP understands Kotlin directly instead of pretending everything is Java, which makes it roughly twice as fast. On a phone, where the build is running on the same battery you are holding, that is not a small difference.

The main thread rule

The draws every frame and handles every tap. Block it for 16 milliseconds and you drop a frame. Block it for five seconds and Android shows the dialog.

Disk reads take an unpredictable amount of time. So Room refuses:

java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

This is a guard rail, not an insult. Mark DAO functions suspend, or return a Flow, and Room handles the threading for you — you will never see this error.

Changing the shape later

Add a field to Note and you have changed the . The database on the user's phone still has the old columns. Room notices and refuses to open:

Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number.

Bump version = 1 to version = 2 and you get the next refusal:

A migration from 1 to 2 was required but not found.

That is Room asking the only question that matters: what should happen to data that already exists? You answer with a :

data/NotesDatabase.ktkotlin
1val MIGRATION_1_2 = object : Migration(1, 2) {
2  override fun migrate(db: SupportSQLiteDatabase) {
3    db.execSQL(
4      "ALTER TABLE notes ADD COLUMN pinned " +
5        "INTEGER NOT NULL DEFAULT 0"
6    )
7  }
8}

Then .addMigrations(MIGRATION_1_2) on the builder.

Careful

There is a shortcut called . It means "if the shape changed, delete everything and start fresh".

While you are the only person with the app installed, that is a reasonable trade. The moment one real user has data in it, it is a bug that deletes their work with no warning and no undo.

Both course apps ship without it. Adding it later is one line; removing the habit is much harder.

Try it in Pocket Studio

You will watch a live Room query update the screen by itself.

  1. Open Pocket Studio, tap Projects, then Pocket Notes.
  2. Tap Editor and open data/NoteDao.kt.
  3. Change the observeAll query from ORDER BY updatedAt DESC to ORDER BY title ASC.
  4. Tap Run. The list is now alphabetical, and you changed one string.
  5. Now break it deliberately: change title to titel in the same query.
  6. Tap Run. The build fails, and the message names the column and the line. That is the clerk stopping you at the desk — the app never got built, so no user ever saw it.
  7. Fix the spelling, change the order back to updatedAt DESC, and Run once more.
  8. Add a note, then swipe the app away in your phone's recents view and open it again. Your note is still there. That is persistence.
Error Doctor6 common errors
java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
MeansA DAO function that touches the disk was called from the main thread. Room blocked it rather than let your app freeze.
FixMark the DAO function suspend and call it from viewModelScope.launch { }, or return a Flow for reads. Never use allowMainThreadQueries() — it silences the warning and keeps the freeze.
java.lang.IllegalStateException: Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number.
MeansYour @Entity classes no longer match the database file already on the phone, and version is still the old number.
FixIncrease version in @Database, then supply a for the step you just created. During early development you can also simply uninstall the app, which deletes its database and lets a fresh one be created.
java.lang.IllegalStateException: A migration from 1 to 2 was required but not found. Please provide the necessary Migration path via RoomDatabase.Builder.addMigration(Migration ...) or allow for destructive migrations via one of the RoomDatabase.Builder.fallbackToDestructiveMigration* methods.
MeansYou bumped the version but did not say what to do with existing data. Room will not guess.
FixWrite a Migration(1, 2) with the ALTER TABLE statement and pass it to .addMigrations(...). Reach for fallbackToDestructiveMigration() only while you are the sole user — it deletes everything.
error: Cannot figure out how to save this field into database. You can consider adding a type converter for it.
MeansOne of your entity's properties is a type SQLite has never heard of — a Date, an enum, a list, another data class. SQLite stores numbers, text and blobs only.
FixEither store something simpler (a Long of milliseconds instead of a Date, a String for an enum name), or write a : two small functions marked @TypeConverter, registered with @TypeConverters on the database.
e: [ksp] ... error: There is a problem with the query: [SQLITE_ERROR] SQL error or missing database (no such column: titel)
MeansRoom ran your query against the real table shape at build time and it does not fit. Usually a typo; sometimes a column you renamed in the entity but not in the query.
FixFix the spelling in the @Query string. Note that this is a BUILD failure, not a crash — this whole class of bug never reaches a user, which is the main reason to use Room over raw SQLite.
e: ksp-2.0.21-1.0.25 is too old for kotlin-2.1.0. Please upgrade ksp or downgrade kotlin-gradle-plugin
MeansThe KSP plugin version and the Kotlin version disagree. KSP is built against one exact Kotlin release.
FixIn gradle/libs.versions.toml, make the part of the KSP version before the dash match your Kotlin version. This course pins kotlin = "2.1.0" and ksp = "2.1.0-1.0.29".
Recap
  • is a checked layer over , the database already built into every Android phone.
  • Three pieces: is a table, is the list of operations, @Database ties them together and opens the file.
  • Room's generator runs through , added with ksp(...) — never implementation(...).
  • Reads that must stay fresh return a . Everything that writes is , so it never blocks the .
  • Open the database once and share it — a built with applicationContext.
  • Change the and you must bump version and write a . The destructive shortcut deletes real people's data.
  • Next: a database is overkill for "the timer is 25 minutes". DataStore is the right size for small settings.