Room Database Basics
Room sits on top of SQLite and removes most of the boilerplate that raw SQLite programming demands: manual cursor handling, hand-written SQL string concatenation, and no compile-time safety net for typos in column names. Room is built from three core pieces that work together — the @Entity classes that define your tables, the @Dao interfaces that define your queries, and a @Database class that ties entities and DAOs together into a single access point. Because Room validates every @Query annotation against your schema at compile time, a misspelled column name or a query that references a non-existent table fails the build instead of crashing at runtime. Room also integrates natively with Kotlin coroutines and Flow, so a DAO method can return a Flow<List<T>> that automatically re-emits whenever the underlying table changes, giving you reactive UI updates for free without wiring up ContentObservers by hand.
Cricket analogy: Room is like having a certified scorer instead of hand-tallying runs on scraps of paper; @Entity classes are the scorebook's columns, @Dao is the scorer's rulebook of allowed entries, and Flow<List<T>> auto-updates the live scoreboard whenever a run is added.
Entities, DAOs, and the Database class
An @Entity is a plain Kotlin data class annotated so Room can generate a matching table; each property becomes a column unless annotated with @Ignore, and exactly one property must be marked @PrimaryKey. A @Dao interface declares the operations you can perform against that table — @Insert, @Update, @Delete for mutations, and @Query for anything more specific, including joins and aggregate functions. The @Database abstract class lists every entity it manages, declares a schema version, and exposes abstract functions that return each DAO. You obtain an instance of this class through Room.databaseBuilder, typically wrapped in a singleton so the app never opens more than one connection to the same file.
Cricket analogy: An @Entity is like a scorecard template with exactly one designated column (the @PrimaryKey, like a unique match ID), while the @Dao is the scorer's permitted actions — record a run, correct an entry, query the innings total via joins.
Suspend functions and Flow in DAOs
Room supports two complementary styles of DAO method. One-shot operations — inserting a row, deleting a row, running a single lookup — should be marked suspend so Room dispatches them off the main thread automatically and the call site can simply await the result inside a coroutine. Observational queries, where the UI needs to stay in sync with table contents over time, should return Flow<T> instead; Room manages the underlying invalidation tracking so the Flow emits a fresh list every time a relevant INSERT, UPDATE, or DELETE commits. Never call a non-suspend, non-Flow Room query directly on the main thread — Room throws an IllegalStateException by default specifically to prevent accidental main-thread database access, a safeguard you should not disable in production code.
Cricket analogy: Recording a single ball's outcome is a quick suspend operation you can await without blocking the whole broadcast, while a live win-probability graph needs a Flow that re-emits with every ball, just as Room forbids main-thread queries to prevent a frozen scoreboard.
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val isDone: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)
@Dao
interface TaskDao {
@Insert
suspend fun insert(task: TaskEntity): Long
@Update
suspend fun update(task: TaskEntity)
@Delete
suspend fun delete(task: TaskEntity)
@Query("SELECT * FROM tasks WHERE isDone = 0 ORDER BY createdAt DESC")
fun observePendingTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :taskId")
suspend fun getTaskById(taskId: Long): TaskEntity?
}
@Database(entities = [TaskEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
).build().also { INSTANCE = it }
}
}
}Every schema change bumps the version integer on @Database, and Room requires you to supply a Migration object describing exactly how to transform the old schema into the new one via raw SQL, unless you explicitly opt into fallbackToDestructiveMigration, which simply wipes and recreates the database — acceptable during early development but destructive to real user data in production. Setting exportSchema = true writes a JSON snapshot of each version to disk, which the Room migration testing library can then use to verify a Migration actually produces the expected end schema before you ship it.
Cricket analogy: Changing a scoring rule mid-tournament (like adding a Super Over column) requires a documented Migration explaining exactly how old scorecards convert, unless you accept wiping all historical scores — fine in a practice match, unacceptable in a World Cup final.
A useful mental model: Room is to SQLite what a strongly typed ORM is to a database — you still get real SQL power (joins, indices, transactions) through @Query, but the compiler catches structural mistakes before your users ever do.
A common pitfall is instantiating Room.databaseBuilder more than once for the same file path, e.g. inside a Composable or a short-lived ViewModel. Each instance opens its own connection and can lead to database-locked errors or missed change notifications. Always obtain the database through a single, application-scoped instance, ideally injected via Hilt.
- Room consists of @Entity tables, @Dao query interfaces, and an abstract @Database that wires them together.
- @Query strings are validated against the schema at compile time, catching typos before runtime.
- One-shot DAO operations should be suspend functions; continuously observed queries should return Flow<T>.
- Room blocks main-thread database access by default — this safeguard should not be disabled.
- Schema changes require an explicit Migration, or exportSchema/fallbackToDestructiveMigration for dev-time flexibility.
- The database instance should be a single, application-scoped singleton, not created per screen or per ViewModel.
Practice what you learned
1. What happens if a Room @Query references a column that does not exist in the associated @Entity?
2. Which return type should a DAO method use to continuously observe changes to a table?
3. What is the default behavior if you attempt to run a blocking Room query directly on the main thread?
4. What must you supply when incrementing a Room database's version if you don't want to lose existing data?
5. Why is it recommended to obtain AppDatabase through a singleton pattern?
Was this page helpful?
You May Also Like
Repository Pattern in Android
The repository pattern centralizes data access behind a single abstraction, hiding whether data comes from Room, Retrofit, or DataStore from ViewModels and the UI layer.
Kotlin Flow Fundamentals
Flow is Kotlin's asynchronous stream type for emitting multiple sequential values over time, built on coroutines, and central to reactive state management in Android apps.
ViewModel Basics
What Android's ViewModel class does, how it survives configuration changes, and how to create and scope ViewModels correctly in a Compose app.
DataStore for Preferences
Jetpack DataStore is the modern, coroutine-based replacement for SharedPreferences, offering asynchronous, transactionally safe, Flow-driven key-value and typed storage.