In Step 7 you learned how ViewModel and LiveData keep your UI data alive across screen rotation. But there's a gap: ViewModel doesn't survive process death. When Android kills your app due to low memory, everything stored in ViewModel is gone. The next time the user opens the app, they're starting from scratch.
Room is the solution. It's Android's official persistence library — a type-safe, compile-time-verified SQLite wrapper that stores data permanently on the device. Room integrates directly with Kotlin Coroutines and LiveData, so reading and writing data feels as clean as working with any other Kotlin code.
Why Room Instead of Raw SQLite?
Android has had SQLite support since version 1.0. The problem is raw SQLite is painful — you write SQL strings that the compiler can't verify, manage cursors manually, run database calls on the wrong thread and crash, and deal with boilerplate that dwarfs the actual logic. Room fixes all of this:
| Room | Raw SQLite | |
|---|---|---|
| SQL verification | ✅ Compile-time — errors caught before running | ❌ Runtime — crashes in production |
| Boilerplate | ✅ Minimal — annotations generate the code | ❌ Extensive cursor and ContentValues management |
| Thread safety | ✅ Enforced — won't run on main thread | ❌ Manual — easy to block UI thread |
| Coroutines support | ✅ Native — suspend functions built in | ❌ Manual thread management |
| LiveData / Flow | ✅ Returns LiveData or Flow directly | ❌ Manual observer wiring |
1. The Three Components of Room
Room has exactly three parts. Understand their roles before writing any code:
- Entity — a Kotlin data class annotated with
@Entity. Each entity maps to one table in the database. Each property maps to a column. - DAO (Data Access Object) — an interface annotated with
@Dao. You declare your database operations here — Room generates the implementation automatically. - Database — an abstract class annotated with
@Database. It's the main access point, holds all your DAOs, and manages the underlying SQLite file.
2. Add Dependencies
// build.gradle.kts (app)
plugins {
id("com.google.devtools.ksp") version "1.9.0-1.0.13" // KSP for annotation processing
}
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") // Coroutines support
ksp("androidx.room:room-compiler:$roomVersion") // Code generation
}
ksp (Kotlin Symbol Processing) instead of the older kapt annotation processor. KSP is significantly faster — up to 2× build time improvement. Make sure the KSP version matches your Kotlin version.
3. Create an Entity — Your Database Table
An Entity is a data class with @Entity. Each instance of the class becomes one row in the table. Every entity needs a primary key:
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true)
val id: Int = 0, // autoGenerate = Room assigns the ID automatically
val title: String,
val content: String,
val timestamp: Long = System.currentTimeMillis(),
val isPinned: Boolean = false
)
Room maps each property to a column with the same name. To use a different column name:
import androidx.room.ColumnInfo
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "note_title") val title: String,
@ColumnInfo(name = "note_content") val content: String,
val timestamp: Long = System.currentTimeMillis()
)
4. Create a DAO — Define Your Database Operations
The DAO is an interface where you declare every operation your app needs — insert, update, delete, and query. Room generates the full implementation. You never write JDBC or cursor code:
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
// INSERT — returns the new row ID
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(note: Note): Long
// UPDATE
@Update
suspend fun update(note: Note)
// DELETE
@Delete
suspend fun delete(note: Note)
// DELETE by ID — custom query
@Query("DELETE FROM notes WHERE id = :noteId")
suspend fun deleteById(noteId: Int)
// GET ALL — returns Flow so UI updates automatically when data changes
@Query("SELECT * FROM notes ORDER BY isPinned DESC, timestamp DESC")
fun getAllNotes(): Flow<List<Note>>
// GET SINGLE NOTE
@Query("SELECT * FROM notes WHERE id = :noteId")
suspend fun getNoteById(noteId: Int): Note?
// SEARCH
@Query("SELECT * FROM notes WHERE title LIKE '%' || :query || '%' OR content LIKE '%' || :query || '%'")
fun searchNotes(query: String): Flow<List<Note>>
// COUNT
@Query("SELECT COUNT(*) FROM notes")
suspend fun getNoteCount(): Int
}
suspend for one-shot operations (insert, update, delete, single read). Use Flow<List<T>> for queries that should automatically re-emit when the underlying data changes — perfect for feeding a RecyclerView that stays up to date without manual refreshes.
5. Create the Database Class
The Database class is the entry point to your entire Room setup. It must be abstract, extend RoomDatabase, and declare all your entities and DAOs. Use a singleton — you should only ever have one database instance in your app:
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [Note::class], // list all entities here
version = 1, // increment when you change the schema
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
// Return existing instance if available — avoids creating multiple connections
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database" // name of the SQLite file on disk
).build()
INSTANCE = instance
instance
}
}
}
}
6. Create a Repository — The Clean Architecture Layer
Don't let your ViewModel talk to the DAO directly. Add a Repository in between. The Repository is the single source of truth for your data — it decides whether to fetch from Room, a network API, or a cache. This separation makes your code testable and your ViewModel clean:
import kotlinx.coroutines.flow.Flow
class NoteRepository(private val noteDao: NoteDao) {
// Flow — Room emits a new list every time the notes table changes
val allNotes: Flow<List<Note>> = noteDao.getAllNotes()
suspend fun insert(note: Note): Long = noteDao.insert(note)
suspend fun update(note: Note) = noteDao.update(note)
suspend fun delete(note: Note) = noteDao.delete(note)
suspend fun deleteById(noteId: Int) = noteDao.deleteById(noteId)
fun searchNotes(query: String): Flow<List<Note>> = noteDao.searchNotes(query)
}
7. Connect to ViewModel
The ViewModel holds the repository and exposes data to the UI. Since Room operations must run off the main thread, use viewModelScope with coroutines — exactly as covered in Step 7:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class NoteViewModel(private val repository: NoteRepository) : ViewModel() {
// Convert Flow to StateFlow — survives recomposition and rotation
val notes: StateFlow<List<Note>> = repository.allNotes
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun addNote(title: String, content: String) {
viewModelScope.launch {
repository.insert(Note(title = title, content = content))
}
}
fun updateNote(note: Note) {
viewModelScope.launch {
repository.update(note)
}
}
fun deleteNote(note: Note) {
viewModelScope.launch {
repository.delete(note)
}
}
}
8. ViewModelFactory — Inject the Repository
ViewModel with constructor parameters needs a Factory. This is how you pass the repository in:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
class NoteViewModelFactory(private val repository: NoteRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(NoteViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return NoteViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
9. Wire Everything Together in Activity
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: NoteViewModel by viewModels {
val db = AppDatabase.getDatabase(applicationContext)
val repository = NoteRepository(db.noteDao())
NoteViewModelFactory(repository)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Collect Flow from ViewModel — updates UI whenever notes change in Room
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.notes.collect { notes ->
// Update your RecyclerView adapter
adapter.submitList(notes)
}
}
}
binding.btnAdd.setOnClickListener {
val title = binding.etTitle.text.toString().trim()
val content = binding.etContent.text.toString().trim()
if (title.isNotEmpty()) {
viewModel.addNote(title, content)
binding.etTitle.text?.clear()
binding.etContent.text?.clear()
}
}
}
}
10. Handling Database Migrations
Every time you change your database schema — add a column, rename a table, add a new entity — you must increment the version number and provide a Migration. Without a migration, Room will throw an exception on launch:
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
// Migration from version 1 to version 2 — adding a new column
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// SQLite ALTER TABLE to add the new column
db.execSQL("ALTER TABLE notes ADD COLUMN category TEXT NOT NULL DEFAULT 'General'")
}
}
// Register the migration when building the database
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.addMigrations(MIGRATION_1_2) // always add migrations here
.build()
fallbackToDestructiveMigration() in production. It deletes all user data when the schema changes. Only use it during development when you don't have real data to preserve.
Best Practices
- One database instance, always — use the singleton pattern with
@Volatileandsynchronized. Creating multiple database instances causes data corruption and wastes resources. - Use Flow for queries that drive UI —
Flow<List<T>>from Room re-emits automatically when the table changes. Combine it withcollectAsState()or observe viarepeatOnLifecycleso your RecyclerView always shows current data without manual refreshes. - All Room operations off the main thread — Room enforces this by throwing an exception if you try. Use
suspendfunctions in your DAO and call them fromviewModelScope. - Repository between ViewModel and DAO — never let ViewModel access the DAO directly. The Repository is your single source of truth and makes unit testing significantly easier.
- Never skip migrations — always write a
Migrationwhen you change the schema. Lost user data is the worst kind of bug to ship. - Use
@ColumnInfofor stability — if you rename a property in your Entity, Room sees it as a deleted column and a new one. Use@ColumnInfo(name = "original_name")to keep the column name stable even when your Kotlin property name changes.
Try It Yourself
- Build a Notes app — create a full notes app where the user can add, view, and delete notes. Use Room for persistence, a
NoteViewModelfor state, and a RecyclerView withListAdapterfor display. Kill the app completely and reopen it — verify the notes are still there. - Add a pin feature — add an
isPinnedboolean column to your Note entity. Add a toggle button on each list item. Pinned notes should appear at the top of the list (update the ORDER BY in your DAO query). This requires a schema migration from version 1 to 2. - Add search — add a search bar above your RecyclerView. As the user types, call
searchNotes(query)from your DAO and collect the results. Observe how Room re-emits the filtered list automatically with each keystroke.
Your app now stores data permanently. But so far that data lives only on the device. In Step 9: Networking with Retrofit we learn how to fetch live data from the internet using REST APIs, handle loading and error states, and combine Retrofit with Room for offline-first architecture.
Frequently Asked Questions
What is the difference between Room and SQLite?
Room is an abstraction over SQLite that eliminates boilerplate. Raw SQLite uses unchecked SQL strings that only fail at runtime — Room verifies SQL at compile time, enforces background thread usage, and generates all the cursor code automatically. It also integrates natively with Kotlin Coroutines and Flow.
What is a DAO in Android Room?
DAO (Data Access Object) is an interface where you declare database operations using annotations — @Insert, @Update, @Delete, @Query. Room generates the full implementation at compile time. Methods can return suspend functions for one-shot operations or Flow for reactive queries.
Should I use Flow or LiveData with Room?
Flow is the modern recommendation for new projects — it integrates more naturally with Kotlin Coroutines and ViewModel with StateFlow. LiveData still works and is a valid choice, especially if you're already using it throughout your app. Room supports both.
What happens if I change my schema without a migration?
Room throws an IllegalStateException at launch — the schema on disk doesn't match the entity definitions. Always increment the version in @Database and provide a Migration with the SQL changes. Never use fallbackToDestructiveMigration() in production — it deletes all user data.
- ← Step 7: LiveData and ViewModel — Smarter Data Handling
- Series Hub: Learn Android with Kotlin — Full Roadmap
- Related: Kotlin Coroutines for Android — Suspend Functions, Scopes and Dispatchers
- Related: Step 6: Mastering RecyclerView — Efficient List Rendering
- Related: ViewModel State Management — LiveData, StateFlow & SavedStateHandle
- Room = type-safe SQLite wrapper — compile-time SQL verification, no boilerplate
- Three parts: Entity (table) + DAO (operations) + Database (entry point)
- Use KSP instead of KAPT — up to 2× faster builds
- @PrimaryKey(autoGenerate = true) — Room assigns IDs automatically
- Use suspend for one-shot operations, Flow for reactive queries
- Repository between ViewModel and DAO — single source of truth
- Use singleton pattern for the Database — one instance only
- Always write Migrations when the schema changes — never
fallbackToDestructiveMigration()in production - Room enforces background threads — use
viewModelScopefor all operations