Introduction
The Android Room database is Google’s recommended way to store structured data locally in an Android app. Room sits on top of SQLite and gives you compile-time checked queries, Kotlin coroutine and Flow support, and far less boilerplate than the raw SQLite APIs.
This guide builds a small offline-first booking list with Room and Kotlin. You will set up dependencies with KSP, define an entity, a DAO and a database, expose data with Flow, handle schema changes with migrations, model relationships, and test everything without a device. Every code block here is meant to compile as written. If you spot a version difference, check the official Room release notes for the current stable version.
What you will learn
- How to set up Room with KSP
- How to write entities, DAOs and the database class
- Why Flow is usually the better choice than LiveData for new code
- How to write migrations that do not wipe user data
- How to model one-to-many relationships
- How to test your DAO with an in-memory database
What is the Android Room database?
Room is part of Android Jetpack. It has three main parts:
- Entity: a Kotlin class that maps to a table.
- DAO (Data Access Object): an interface where you declare your queries and write operations.
- Database: an abstract class that ties entities and DAOs together and gives you the database instance.
Room checks your SQL at compile time, so a typo in a column name fails the build instead of crashing your app in production.
Setting up Room with Kotlin and KSP
Use KSP, not annotationProcessor, in Kotlin projects. Add the KSP plugin to your module, then the dependencies:
// build.gradle.kts (app)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
dependencies {
val roomVersion = "2.6.1" // check the latest stable release
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
// Lifecycle libraries have their own versions, not Room's
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:<lifecycle_version>")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:<lifecycle_version>")
testImplementation("androidx.room:room-testing:$roomVersion")
}
Sync the project after adding these. One common mistake in older tutorials is reusing the Room version number for the lifecycle libraries. They are separate libraries with separate versions.
Step 1: Define an entity
An entity is a table. Each property is a column.
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "bookings")
data class Booking(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val guestName: String,
val roomNumber: Int,
val checkInEpochDay: Long
)
The id is auto-generated on insert. Dates are stored as an epoch-day Long, which is simple and sorts correctly. If you prefer LocalDate, see the TypeConverters section below.
Step 2: Create the DAO
The DAO declares how you read and write data. Write operations are suspend functions, and reads return Flow.
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface BookingDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(booking: Booking): Long
@Update
suspend fun update(booking: Booking)
@Delete
suspend fun delete(booking: Booking)
@Query("SELECT * FROM bookings ORDER BY checkInEpochDay ASC")
fun observeAll(): Flow<List<Booking>>
@Query("SELECT * FROM bookings WHERE id = :id")
suspend fun getById(id: Long): Booking?
}
Room runs suspend DAO functions off the main thread for you, so you do not need withContext(Dispatchers.IO) around them. The Flow re-emits automatically whenever the bookings table changes.
Step 3: Create the database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Booking::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun bookingDao(): BookingDao
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 }
}
}
}
Set exportSchema = true and configure a schema directory in Gradle. Exported schema JSON files are what let you write and test migrations later. Many tutorials set it to false and then have no safe path for schema changes.
If you use Hilt, provide the database and DAO from a module instead of the singleton above.
Step 4: Repository and ViewModel
The repository keeps your UI independent of Room.
class BookingRepository(private val dao: BookingDao) {
val bookings: Flow<List<Booking>> = dao.observeAll()
suspend fun add(booking: Booking) = dao.insert(booking)
suspend fun remove(booking: Booking) = dao.delete(booking)
}
The ViewModel turns the Flow into state the UI can collect, using viewModelScope, which is cancelled automatically when the ViewModel is cleared:
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class BookingViewModel(app: Application) : AndroidViewModel(app) {
private val repository =
BookingRepository(AppDatabase.getInstance(app).bookingDao())
val bookings: StateFlow<List<Booking>> = repository.bookings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun add(guestName: String, roomNumber: Int, checkInEpochDay: Long) {
viewModelScope.launch {
repository.add(Booking(guestName = guestName, roomNumber = roomNumber, checkInEpochDay = checkInEpochDay))
}
}
fun remove(booking: Booking) {
viewModelScope.launch { repository.remove(booking) }
}
}
Step 5: Collect the data in your UI
Use ViewBinding, since kotlinx.android.synthetic has been removed, and collect the Flow in a lifecycle-aware way:
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: BookingViewModel by viewModels()
private val adapter = BookingAdapter() // your ListAdapter<Booking, ...>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.recyclerView.adapter = adapter
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.bookings.collect { adapter.submitList(it) }
}
}
binding.buttonAdd.setOnClickListener {
viewModel.add("John Doe", 101, LocalDate.now().toEpochDay())
}
}
}
In Jetpack Compose, replace the collection block with viewModel.bookings.collectAsStateWithLifecycle().
Room with Flow vs LiveData
Room supports both. For new code, Flow is usually the better default:
| Flow | LiveData | |
|---|---|---|
| Works outside Android UI code (repositories, tests) | Yes | Awkward |
| Operators (map, combine, debounce) | Rich | Limited |
| Lifecycle awareness | Via repeatOnLifecycle or collectAsStateWithLifecycle | Built in |
| Compose support | First-class | Needs an adapter |
If your project already uses LiveData everywhere, there is no urgent reason to rewrite it. Mixing both in one screen is where problems start.
TypeConverters
Room stores primitives and strings. For other types, add a converter:
class Converters {
@TypeConverter fun fromLocalDate(value: LocalDate?): Long? = value?.toEpochDay()
@TypeConverter fun toLocalDate(value: Long?): LocalDate? = value?.let(LocalDate::ofEpochDay)
}
@Database(entities = [Booking::class], version = 1, exportSchema = true)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { /* ... */ }
One-to-many relations
Say each guest has many bookings. Store guestId on the booking, then describe the relation:
@Entity(tableName = "guests")
data class Guest(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String
)
data class GuestWithBookings(
@Embedded val guest: Guest,
@Relation(parentColumn = "id", entityColumn = "guestId")
val bookings: List<Booking>
)
@Dao
interface GuestDao {
@Transaction
@Query("SELECT * FROM guests")
fun observeGuestsWithBookings(): Flow<List<GuestWithBookings>>
}
The @Transaction annotation matters. Room runs two queries for a relation, and the transaction keeps the result consistent. Add @Index on guestId so lookups stay fast as the table grows.
Database migrations
This is where most real apps get hurt. If you change an entity and bump the version without a migration, Room throws an exception at startup, or you wipe user data if you used fallbackToDestructiveMigration().
Suppose version 2 adds a nullable notes column:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE bookings ADD COLUMN notes TEXT")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
The entity gets val notes: String? = null and the database version becomes 2. For simple changes such as adding a column, Room also supports auto-migrations with @Database(autoMigrations = [...]). Both approaches depend on the exported schema files from the database step.
Rule of thumb: never ship fallbackToDestructiveMigration() in a production app that holds user data.
Testing your DAO
Use an in-memory database so tests are fast and leave nothing behind:
@RunWith(AndroidJUnit4::class)
class BookingDaoTest {
private lateinit var db: AppDatabase
private lateinit var dao: BookingDao
@Before fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(), AppDatabase::class.java
).build()
dao = db.bookingDao()
}
@After fun tearDown() = db.close()
@Test fun insertAndRead() = runTest {
dao.insert(Booking(guestName = "Asha", roomNumber = 12, checkInEpochDay = 20000))
val all = dao.observeAll().first()
assertEquals(1, all.size)
assertEquals("Asha", all.first().guestName)
}
}
Room also provides MigrationTestHelper in room-testing to verify that a migration produces the schema you expect.
Common Room mistakes
- Querying on the main thread. Use suspend functions or Flow. Do not use
allowMainThreadQueries()outside quick experiments. - Destructive migrations in production. Users lose their data on update.
- Setting
exportSchema = false. You lose the ability to test and automate migrations. - Using Room’s version number for other Jetpack libraries. They are versioned separately.
- Missing indexes on foreign keys. Queries slow down as tables grow.
- Creating a new database instance per screen. Use one instance per app, through a singleton or dependency injection.
Room vs SQLite vs DataStore
| Need | Best fit |
|---|---|
| Structured, queryable, relational data | Room |
| Full control of raw SQL, or an existing SQLite setup | SQLite directly, or Room on top of it |
| A few settings or flags | DataStore |
| Shared data across Kotlin Multiplatform targets | Check Room’s current multiplatform support in the release notes |
Frequently asked questions
Q. Is Room better than raw SQLite on Android?
A. For most apps, yes. Room validates queries at compile time, removes boilerplate and integrates with coroutines and Flow. Raw SQLite is still useful when you need full control.
Q. Should I use Flow or LiveData with Room?
A. Flow is the better default for new code. LiveData is fine if your project is already built around it.
Q. How do I migrate a Room database without losing data?
A. Bump the database version, write a Migration (or use an auto-migration), keep exported schemas, and test with MigrationTestHelper.
Q. Does Room work with Jetpack Compose?
A. Yes. Expose a Flow from your DAO, convert it to StateFlow in the ViewModel, and call collectAsStateWithLifecycle() in your composable.
Q. Can Room store lists or custom objects?
A. Not directly. Use a @TypeConverter, or better, model them as a separate table with a relation.
Build it right the first time
Planning an Android app that has to work offline and survive every update? Your local data model is one of the hardest things to change once users have installed the app, so it pays to get the schema and migration plan right in version one.
