200OK Solutions blog banner titled “Exploring the Android Room Database with Kotlin,” featuring a laptop, database interface, and digital communication icons illustrating Android app development and local data storage using Kotlin.

Exploring the Android Room Database with Kotlin

Share this post on:

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:

FlowLiveData
Works outside Android UI code (repositories, tests)YesAwkward
Operators (map, combine, debounce)RichLimited
Lifecycle awarenessVia repeatOnLifecycle or collectAsStateWithLifecycleBuilt in
Compose supportFirst-classNeeds 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

  1. Querying on the main thread. Use suspend functions or Flow. Do not use allowMainThreadQueries() outside quick experiments.
  2. Destructive migrations in production. Users lose their data on update.
  3. Setting exportSchema = false. You lose the ability to test and automate migrations.
  4. Using Room’s version number for other Jetpack libraries. They are versioned separately.
  5. Missing indexes on foreign keys. Queries slow down as tables grow.
  6. Creating a new database instance per screen. Use one instance per app, through a singleton or dependency injection.

Room vs SQLite vs DataStore

NeedBest fit
Structured, queryable, relational dataRoom
Full control of raw SQL, or an existing SQLite setupSQLite directly, or Room on top of it
A few settings or flagsDataStore
Shared data across Kotlin Multiplatform targetsCheck 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.

Avatar photo

Piyush Solanki

PHP Tech Lead & Backend Architect

10+ years experience
UK market specialist
Global brands & SMEs
Full-stack expertise

Core Technologies

PHP 95%
MySQL 90%
WordPress 92%
AWS 88%
  • Backend: PHP, MySQL, CodeIgniter, Laravel
  • CMS: WordPress customization & plugin development
  • APIs: RESTful design, microservices architecture
  • Frontend: React, TypeScript, modern admin panels
  • Cloud: AWS S3, Linux deployments
  • Integrations: Stripe, SMS/OTP gateways
  • Finance: Secure payment systems & compliance
  • Hospitality: Booking & reservation systems
  • Retail: E-commerce platforms & inventory
  • Consulting: Custom business solutions
  • Food Services: Delivery & ordering systems
  • Modernizing legacy systems for scalability
  • Building secure, high-performance products
  • Mobile-first API development
  • Agile collaboration with cross-functional teams
  • Focus on operational efficiency & innovation

Piyush Solanki is a seasoned PHP Tech Lead with 10+ years of experience architecting and delivering scalable web and mobile backend solutions for global brands and fast-growing SMEs.

He specializes in PHP, MySQL, CodeIgniter, WordPress, and custom API development, helping businesses modernize legacy systems and launch secure, high-performance digital products.

He collaborates closely with mobile teams building Android & iOS apps, developing RESTful APIs, cloud integrations, and secure payment systems. With extensive experience in the UK market and across multiple sectors, Piyush Solanki is passionate about helping SMEs scale technology teams and accelerate innovation through backend excellence.

    Reach Out Us


    Your name

    Your email

    Subject

    Your message