Room — a real database
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 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.
| Piece | Annotation | What it is | |
|---|---|---|---|
| [[entity | Entity]] | @Entity | A class describing one table. |
| [[dao | DAO]] | @Dao | An interface listing the operations you want. |
| Database | @Database | The 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
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:
| id | title | body | updatedAt |
|---|---|---|---|
| 1 | Shopping | Oats, tea, the good bread | 1755277112000 |
| 2 | Ideas | Ring dial, stats, a chime | 1755190000000 |
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
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 :
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:
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.
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.
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 :
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.
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.
You will watch a live Room query update the screen by itself.
- Open Pocket Studio, tap Projects, then Pocket Notes.
- Tap Editor and open
data/NoteDao.kt. - Change the
observeAllquery fromORDER BY updatedAt DESCtoORDER BY title ASC. - Tap Run. The list is now alphabetical, and you changed one string.
- Now break it deliberately: change
titletotitelin the same query. - 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.
- Fix the spelling, change the order back to
updatedAt DESC, and Run once more. - 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.
suspend and call it from viewModelScope.launch { }, or return a Flow for reads. Never use allowMainThreadQueries() — it silences the warning and keeps the freeze.@Entity classes no longer match the database file already on the phone, and version is still the old number.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.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.Date, an enum, a list, another data class. SQLite stores numbers, text and blobs only.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.@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.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".- is a checked layer over , the database already built into every Android phone.
- Three pieces: is a table, is the list of operations,
@Databaseties them together and opens the file. - Room's generator runs through , added with
ksp(...)— neverimplementation(...). - 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
versionand 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.