Local Persistence with Room
Reviewed & published by Brayan K
Room is Android's official persistence library — a type-safe, compile-time-checked layer over SQLite that turns annotated Kotlin classes into a real relational database without the boilerplate.
Part of the free Kotlin course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end you'll define an @Entity, write a reactive @Dao, and wire up a RoomDatabase.
What You'll Learn in This Lesson
- Defining tables with @Entity and @PrimaryKey
- Writing queries in a @Dao interface
- Reactive reads with Flow
- suspend functions for safe writes
- Building a @Database / RoomDatabase
- Migrations and type converters
1️⃣ Defining a Table with @Entity
An @Entity turns a data class into a table: each instance is a row, each property a column. Mark one property with @PrimaryKey, optionally with autoGenerate = true so Room assigns IDs.
import androidx.room.Entity
import androidx.room.PrimaryKey
// @Entity makes this data class a table. Each instance is a row.
@Entity(tableName = "users")
data class User(
// autoGenerate lets Room assign incrementing IDs for you.
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val email: String,
)Room reads these annotations at compile time and generates the matching SQLite schema for you.
2️⃣ Querying with a @Dao
A @Dao interface declares your data access. Use @Query for custom SQL, plus @Insert/@Update/@Delete. Return a Flow for reactive reads, and mark writes suspend.
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
// Returning a Flow makes this query REACTIVE:
// it re-emits automatically whenever the users table changes.
@Query("SELECT * FROM users ORDER BY name")
fun observeAll(): Flow<List<User>>
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getById(id: Int): User?
// Write operations are 'suspend' so they run off the main thread.
@Insert
suspend fun insert(user: User)
@Update
suspend fun update(user: User)
@Delete
suspend fun delete(user: User)
}Because observeAll() returns a Flow, your UI refreshes itself whenever the data changes — no polling required.
3️⃣ The @Database Class
The @Database class extends RoomDatabase, lists every entity and a version, and exposes abstract DAO getters. Build a single instance with Room.databaseBuilder.
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context
// @Database lists every entity and a schema version.
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
// Build ONE instance for the whole app (it is expensive to create).
fun buildDatabase(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.build()Your turn. Replace the TODOs, then compare.
// 🎯 YOUR TURN — replace each TODO.
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "notes")
data class Note(
// 1) Mark id as the primary key with auto-generation.
TODO val id: Int = 0, // 👉 @PrimaryKey(autoGenerate = true)
val text: String,
)
@Dao
interface NoteDao {
// 2) Return a reactive list of all notes.
@Query("SELECT * FROM notes")
fun observeAll(): TODO // 👉 Flow<List<Note>>
@Insert
suspend fun insert(note: Note)
}Mini-Challenge: A Task Store
Model a Task entity, a reactive DAO, and a database that exposes it.
// 🎯 MINI-CHALLENGE: a tiny task store.
//
// 1. Create an @Entity data class Task(id, title, done).
// Make id the auto-generated @PrimaryKey.
// 2. Write a @Dao TaskDao with:
// - a Flow<List<Task>> query for unfinished tasks
// (WHERE done = 0 ORDER BY title)
// - suspend insert(task: Task)
// - suspend markDone(id: Int) using an @Query UPDATE
// 3. Declare an @Database(entities = [Task::class], version = 1)
// that exposes taskDao().
//
// ✅ Inserting a task makes the Flow re-emit with the new row.
// your code hereCommon Errors (and the fix)
- "Cannot access database on the main thread" — Mark write methods suspend and call them from a coroutine, or use a Flow read.
- "A migration from N to N+1 was required but not found" — When you change the schema, bump the version AND add a Migration.
- "Cannot figure out how to save this field" — Add a @TypeConverter for unsupported types like Date.
- Creating a new database every call — databaseBuilder is expensive; build one instance and reuse it.
Pro Tips
- 💡 Flow everywhere for reads: reactive queries keep the UI in sync with zero manual refreshing.
- 💡 Model relationships with @Relation and @Embedded instead of duplicating data.
- 💡 Test your migrations with the Room testing helpers so upgrades never lose user data.
📋 Quick Reference — Room
| Concept | Room API |
|---|---|
| Define a table | @Entity data class User(...) |
| Primary key | @PrimaryKey(autoGenerate = true) |
| Custom query | @Query("SELECT * FROM users") |
| Reactive read | fun all(): Flow<List<User>> |
| Safe write | suspend fun insert(u: User) |
| The database | @Database(...) : RoomDatabase() |
🎉 Lesson Complete!
- ✅ Room is a type-safe layer over SQLite
- ✅ @Entity defines tables; @PrimaryKey identifies rows
- ✅ A @Dao declares queries with @Query/@Insert/@Update/@Delete
- ✅ Flow reads are reactive; writes are suspend
- ✅ Migrations and type converters keep the schema flexible
Practice quiz
What annotation marks a class as a Room database table?
- @Entity
- @Model
- @Persist
- @Table
Answer: @Entity. @Entity marks a data class as a table; each instance is a row and each property a column.
Which library does Room sit on top of?
- Realm
- A custom NoSQL engine
- SQLite
- Firebase
Answer: SQLite. Room is an abstraction layer over Android's built-in SQLite, removing boilerplate while keeping full SQL access.
What annotation marks an interface that defines database access methods?
- @Repository
- @Dao
- @Access
- @Query
Answer: @Dao. A Data Access Object (@Dao) declares the methods used to read and write data.
Which annotation lets you write a custom SELECT statement in a DAO?
- @Sql
- @Select
- @Fetch
- @Query
Answer: @Query. @Query holds an arbitrary SQL statement; Room validates it at compile time.
How do you define the primary key for an entity?
- With @PrimaryKey on a property
- By naming a property 'id'
- Room picks one automatically with no annotation
- With the @Key annotation on the class
Answer: With @PrimaryKey on a property. @PrimaryKey marks the property; add autoGenerate = true to let Room assign incrementing IDs.
Why return a Flow from a Room @Query?
- It encrypts the rows
- It makes the query observable, re-emitting when the underlying data changes
- It runs the query on the main thread
- It compresses the result
Answer: It makes the query observable, re-emitting when the underlying data changes. A Flow-returning query is reactive: Room re-emits a fresh result whenever the table's data changes.
Why should insert/update/delete DAO methods usually be 'suspend' functions?
- Because Room requires it for compilation
- To make them faster than SQL
- So they return a Flow automatically
- So they run off the main thread without blocking the UI
Answer: So they run off the main thread without blocking the UI. Marking write methods suspend lets you call them from coroutines so disk I/O never blocks the UI thread.
What class must your database class extend, annotated with @Database?
- DatabaseHelper
- ContentProvider
- RoomDatabase
- SQLiteOpenHelper
Answer: RoomDatabase. The @Database-annotated abstract class extends RoomDatabase and exposes abstract DAO getters.
What is a Room migration used for?
- Moving the app to a new device
- Defining how to upgrade the schema from one version to the next
- Copying data to the cloud
- Switching from SQLite to MySQL
Answer: Defining how to upgrade the schema from one version to the next. A Migration tells Room the SQL needed to evolve the schema when the database version increases, preserving user data.
What does a @TypeConverter let Room do?
- Store a custom type (like Date) by converting it to and from a type SQLite supports
- Convert Kotlin to Java
- Encrypt a column
- Convert between SQL dialects
Answer: Store a custom type (like Date) by converting it to and from a type SQLite supports. A @TypeConverter maps an unsupported type (such as Date or an enum) to a primitive SQLite can store, and back.
Continue this course
- Previous: Kotlin Multiplatform Intro
- Next: Dependency Injection with Hilt — Wire dependencies with Hilt modules, scopes and @Inject
- Quick reference: Kotlin cheat sheet
Frequently asked questions
What is Room and how does it relate to SQLite?
Room is Android's official persistence library, part of Jetpack. It sits as a thin, compile-time-checked layer over SQLite — the relational database that ships with every Android device. You describe your data with annotated Kotlin classes (@Entity), declare your queries in a DAO interface, and Room generates the verbose, error-prone SQLite boilerplate for you. You still get full SQL power through @Query, but with type safety: Room verifies your SQL against your schema at compile time, so a typo in a column name is a build error, not a crash.
What are the three core Room components?
Every Room setup has three pieces. Entities are data classes annotated with @Entity that define your tables. DAOs (Data Access Objects) are interfaces annotated with @Dao that declare your read/write methods using @Query, @Insert, @Update, and @Delete. The database is an abstract class annotated with @Database that extends RoomDatabase, lists its entities and a version number, and exposes abstract getters for each DAO. You build a single instance of it with Room.databaseBuilder and reuse it across the app.
Why return a Flow from a query, and why mark writes as suspend?
Returning Flow<List<T>> makes a query reactive: Room observes the underlying tables and re-emits a fresh result whenever the data changes, so your UI updates automatically without manual refreshing. Write operations (@Insert, @Update, @Delete) touch the disk, which must never happen on the main thread, so you mark them suspend and call them from a coroutine. Together, suspend writes plus Flow reads give you a clean, reactive, non-blocking data layer.
What are migrations and type converters for?
When you change your schema — add a column, a table, an index — you increase the @Database version and provide a Migration that contains the SQL to transform the old schema into the new one, preserving the user's existing data. Skipping this and just bumping the version will crash with a missing-migration error. Type converters solve a different problem: SQLite only stores a handful of primitive types, so to persist something like a Date or an enum you write a @TypeConverter that turns it into a supported type (e.g. a Long timestamp) and back. For linking tables, Room also supports relationships via @Relation and embedded objects.