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 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 (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.
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.
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.
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.
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.
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.
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.
@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 : AutoMigrationSpecAuto-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.
@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.
@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.
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.
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.
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.
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
@Transactionfor relation queries to ensure consistent reads - Wrap database operations in Result for clean error handling in ViewModels
- Test migrations with
MigrationTestHelperbefore 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
Share
Related articles

Kotlin Flow vs StateFlow vs SharedFlow: Android Interview Questions in 2026
The Kotlin Flow vs StateFlow vs SharedFlow questions Android interviewers ask in 2026, with clear answers, a comparison table, and production-ready code.

Mastering Kotlin Coroutines: Complete 2026 Guide
Learn to master Kotlin coroutines for Android development: suspend functions, scopes, dispatchers, and advanced patterns.

Kotlin 2.3 for Android: Name-Based Destructuring, KMP and Interview Questions 2026
Kotlin 2.3 interview questions covering name-based destructuring, Kotlin Multiplatform, context parameters, coroutines and Flow. Prepare for Android developer interviews in 2026 with real-world code examples.