Room Database in Android 2026: Migrations, Relations and Coroutines

Master Room database in Android with migrations, entity relations, Kotlin coroutines and Flow. Complete tutorial with production-ready patterns for 2026.

Room Database Android Kotlin SQLite data persistence

Room database provides a robust abstraction layer over SQLite in Android, handling the boilerplate code that makes raw SQLite tedious to work with. With Room 2.6 and Kotlin coroutines, building reactive, type-safe database layers has become significantly more straightforward.

Room 2.6 Key Features

Room 2.6 (stable in 2026) introduces improved migration support, enhanced KSP processing speed, and better Flow integration. This tutorial uses these latest APIs for production-ready code.

Setting Up Room Database with Kotlin KSP

Room requires three core components: entities (tables), DAOs (data access objects), and the database class. The KSP annotation processor generates implementation code at compile time.

Add the dependencies to your module-level build.gradle.kts. KSP replaced KAPT as the recommended annotation processor since 2024, offering faster build times.

build.gradle.kts (Module :app)kotlin
plugins {
    id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}

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")
}

The room-ktx artifact provides coroutine extensions, allowing suspend functions in DAOs.

Defining Entities with Primary Keys and Indices

Entities map directly to SQLite tables. Each entity class needs at least one primary key. Adding indices on frequently queried columns improves read performance at the cost of slightly slower writes.

User.ktkotlin
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "users",
    indices = [
        Index(value = ["email"], unique = true), // Unique constraint + index
        Index(value = ["created_at"]) // Index for sorting queries
    ]
)
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val email: String,
    val displayName: String,
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis(),
    val isActive: Boolean = true
)

The @ColumnInfo annotation customizes the column name when the Kotlin property name differs from the desired database column name.

Building DAOs with Coroutines and Flow

DAOs define the database operations. Room 2.6 supports three async patterns: suspend functions for one-shot operations, Flow for reactive streams, and ListenableFuture for Java interop.

UserDao.ktkotlin
import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface UserDao {
    // One-shot insert - returns the generated ID
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User): Long

    // Batch insert - efficient for multiple records
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertUsers(users: List<User>)

    // Flow emits new data whenever the table changes
    @Query("SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC")
    fun observeActiveUsers(): Flow<List<User>>

    // Suspend function for one-shot read
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Long): User?

    // Update returns the number of affected rows
    @Update
    suspend fun updateUser(user: User): Int

    // Delete by primary key
    @Delete
    suspend fun deleteUser(user: User)

    // Custom delete query
    @Query("DELETE FROM users WHERE is_active = 0")
    suspend fun deleteInactiveUsers(): Int
}

Flow-returning queries automatically emit updated results when underlying data changes. This eliminates manual refresh logic in ViewModels.

Creating the Room Database Class

The database class ties entities and DAOs together. A singleton pattern prevents multiple database instances, which would cause resource leaks.

AppDatabase.ktkotlin
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [User::class, Post::class],
    version = 1,
    exportSchema = true // Generates schema JSON for migration validation
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun postDao(): PostDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                )
                    .fallbackToDestructiveMigration() // Only for development
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Setting exportSchema = true generates JSON schema files in schemas/ folder, useful for validating migrations in tests.

Production Migration Strategy

Never use fallbackToDestructiveMigration() in production. This deletes all user data when the schema changes. The next section covers proper migration handling.

Writing Safe Database Migrations

Migrations preserve user data when the schema changes between app versions. Room validates migrations at runtime by comparing the expected schema with the migrated database.

A migration defines the SQL statements needed to transform the database from version N to version N+1.

Migrations.ktkotlin
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

// Migration from version 1 to 2: add phone_number column
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE users ADD COLUMN phone_number TEXT")
    }
}

// Migration from version 2 to 3: add posts table with foreign key
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                user_id INTEGER NOT NULL,
                title TEXT NOT NULL,
                content TEXT NOT NULL,
                created_at INTEGER NOT NULL,
                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        """.trimIndent())
        db.execSQL("CREATE INDEX index_posts_user_id ON posts(user_id)")
    }
}

// Register migrations in database builder
fun getInstance(context: Context): AppDatabase {
    return INSTANCE ?: synchronized(this) {
        Room.databaseBuilder(
            context.applicationContext,
            AppDatabase::class.java,
            "app_database"
        )
            .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
            .build()
            .also { INSTANCE = it }
    }
}

Room supports migration chaining. If a user upgrades from version 1 to version 3, Room executes MIGRATION_1_2 then MIGRATION_2_3 sequentially.

Auto-Migrations in Room 2.6

For simple schema changes (adding columns, tables, or indices), Room can generate migrations automatically. This reduces boilerplate for straightforward updates.

AppDatabase.ktkotlin
@Database(
    entities = [User::class, Post::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2), // Adding nullable column
        AutoMigration(
            from = 2,
            to = 3,
            spec = AutoMigration2To3::class // Complex changes need spec
        )
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    // DAOs...
}

// Migration spec for changes that need hints
@RenameColumn(tableName = "users", fromColumnName = "name", toColumnName = "display_name")
class AutoMigration2To3 : AutoMigrationSpec

Auto-migrations require exportSchema = true to compare versions. For column renames, table renames, or deletions, provide an AutoMigrationSpec with the appropriate annotations.

Ready to ace your Android interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Modeling Entity Relations with Embedded Objects

Room supports three relation patterns: embedded objects, one-to-many, and many-to-many. Unlike ORMs, Room returns flat data—relations require explicit queries.

One-to-Many: User with Posts

A user can have multiple posts. Define the relation using @Relation annotation in a data class that combines both entities.

Post.ktkotlin
@Entity(
    tableName = "posts",
    foreignKeys = [
        ForeignKey(
            entity = User::class,
            parentColumns = ["id"],
            childColumns = ["user_id"],
            onDelete = ForeignKey.CASCADE // Delete posts when user deleted
        )
    ],
    indices = [Index("user_id")] // Required for foreign key performance
)
data class Post(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "user_id") val userId: Long,
    val title: String,
    val content: String,
    @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

// UserWithPosts.kt - Relation container
data class UserWithPosts(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "user_id"
    )
    val posts: List<Post>
)

// In UserDao.kt
@Transaction // Ensures atomic read of user + posts
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserWithPosts(userId: Long): UserWithPosts?

@Transaction
@Query("SELECT * FROM users WHERE is_active = 1")
fun observeActiveUsersWithPosts(): Flow<List<UserWithPosts>>

The @Transaction annotation prevents inconsistent reads when the relation spans multiple queries internally.

Many-to-Many: Users and Tags

Many-to-many relations require a junction (cross-reference) table storing pairs of foreign keys.

Tag.ktkotlin
@Entity(tableName = "tags")
data class Tag(
    @PrimaryKey(autoGenerate = true) val tagId: Long = 0,
    val name: String
)

// UserTagCrossRef.kt - Junction table
@Entity(
    tableName = "user_tag_cross_ref",
    primaryKeys = ["userId", "tagId"],
    foreignKeys = [
        ForeignKey(entity = User::class, parentColumns = ["id"], childColumns = ["userId"], onDelete = ForeignKey.CASCADE),
        ForeignKey(entity = Tag::class, parentColumns = ["tagId"], childColumns = ["tagId"], onDelete = ForeignKey.CASCADE)
    ]
)
data class UserTagCrossRef(
    val userId: Long,
    val tagId: Long
)

// UserWithTags.kt
data class UserWithTags(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "tagId",
        associateBy = Junction(UserTagCrossRef::class)
    )
    val tags: List<Tag>
)

The associateBy parameter specifies the junction table that links users to tags.

Integrating Room with ViewModel and Repository

A clean architecture separates the database layer from UI. The repository pattern abstracts data sources, while ViewModel exposes state to Compose UI.

UserRepository.ktkotlin
class UserRepository(private val userDao: UserDao) {

    val activeUsers: Flow<List<User>> = userDao.observeActiveUsers()

    suspend fun createUser(email: String, displayName: String): Long {
        val user = User(email = email, displayName = displayName)
        return userDao.insertUser(user)
    }

    suspend fun deactivateUser(userId: Long) {
        userDao.getUserById(userId)?.let { user ->
            userDao.updateUser(user.copy(isActive = false))
        }
    }
}

// UserViewModel.kt
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val users: StateFlow<List<User>> = repository.activeUsers
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun createUser(email: String, name: String) {
        viewModelScope.launch {
            repository.createUser(email, name)
            // No manual refresh needed - Flow emits automatically
        }
    }
}

The stateIn operator converts the cold Flow from Room into a hot StateFlow suitable for Compose collection. For more details on coroutines patterns, see the guide on Kotlin Coroutines.

Testing Room Databases

Room provides an in-memory database builder for testing: Room.inMemoryDatabaseBuilder(). Tests run faster and don't persist data between runs. Always test migrations with MigrationTestHelper from the room-testing artifact.

Type Converters for Complex Types

Room only stores primitive types natively. Type converters transform complex types (dates, enums, lists) to storable formats.

Converters.ktkotlin
import androidx.room.TypeConverter
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset

class Converters {
    // Instant <-> Long (milliseconds)
    @TypeConverter
    fun fromInstant(instant: Instant?): Long? = instant?.toEpochMilli()

    @TypeConverter
    fun toInstant(millis: Long?): Instant? = millis?.let { Instant.ofEpochMilli(it) }

    // LocalDate <-> String (ISO format)
    @TypeConverter
    fun fromLocalDate(date: LocalDate?): String? = date?.toString()

    @TypeConverter
    fun toLocalDate(dateStr: String?): LocalDate? = dateStr?.let { LocalDate.parse(it) }

    // List<String> <-> JSON string
    @TypeConverter
    fun fromStringList(list: List<String>?): String? =
        list?.joinToString(separator = ",")

    @TypeConverter
    fun toStringList(data: String?): List<String>? =
        data?.split(",")?.filter { it.isNotBlank() }
}

// Register in database class
@Database(...)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

For JSON serialization of complex objects, consider using kotlinx.serialization instead of manual string manipulation.

Handling Database Operations in Coroutines Scope

Room DAO operations should run on IO dispatcher. While Room handles threading internally for suspend functions, repository-level error handling keeps the code robust.

UserRepository.kt with error handlingkotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class UserRepository(private val userDao: UserDao) {

    suspend fun createUser(email: String, displayName: String): Result<Long> {
        return withContext(Dispatchers.IO) {
            try {
                val id = userDao.insertUser(
                    User(email = email, displayName = displayName)
                )
                Result.success(id)
            } catch (e: SQLiteConstraintException) {
                // Unique constraint violation (duplicate email)
                Result.failure(DuplicateEmailException(email))
            }
        }
    }

    suspend fun deleteAllInactive(): Int = withContext(Dispatchers.IO) {
        userDao.deleteInactiveUsers()
    }
}

class DuplicateEmailException(email: String) : Exception("Email already exists: $email")

Wrapping results in Result<T> lets ViewModels handle errors gracefully without try-catch boilerplate.

Conclusion

Building a production-ready Room database layer involves several key practices:

  • Use KSP instead of KAPT for faster compilation with Room 2.6
  • Add indices on columns used in WHERE clauses and JOIN conditions
  • Return Flow from DAOs for automatic UI updates when data changes
  • Write explicit migrations for schema changes—never use destructive migration in production
  • Use @Transaction for relation queries to ensure consistent reads
  • Wrap database operations in Result for clean error handling in ViewModels
  • Test migrations with MigrationTestHelper before releasing updates

Practice these patterns with Room database interview questions to reinforce the concepts.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#android
#room
#kotlin
#sqlite
#coroutines
#database

Share

Related articles