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

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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Room

ConceptRoom API
Define a table@Entity data class User(...)
Primary key@PrimaryKey(autoGenerate = true)
Custom query@Query("SELECT * FROM users")
Reactive readfun all(): Flow<List<User>>
Safe writesuspend fun insert(u: User)
The database@Database(...) : RoomDatabase()

🎉 Lesson Complete!

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

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.