# 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. - Published: 2026-07-10 - Updated: 2026-07-10 - Author: SharpSkill - Tags: android, room, kotlin, sqlite, coroutines, database - Reading time: 12 min --- 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. ```kotlin // build.gradle.kts (Module :app) 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. ```kotlin // User.kt 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. ```kotlin // UserDao.kt 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) // Flow emits new data whenever the table changes @Query("SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC") fun observeActiveUsers(): Flow> // 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. ```kotlin // AppDatabase.kt 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. ```kotlin // Migrations.kt 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. ```kotlin // AppDatabase.kt @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. ## 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. ```kotlin // Post.kt @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 ) // 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> ``` 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. ```kotlin // Tag.kt @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 ) ``` 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. ```kotlin // UserRepository.kt class UserRepository(private val userDao: UserDao) { val activeUsers: Flow> = 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> = 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](/blog/android/mastering-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. ```kotlin // Converters.kt 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 <-> JSON string @TypeConverter fun fromStringList(list: List?): String? = list?.joinToString(separator = ",") @TypeConverter fun toStringList(data: String?): List? = 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](https://github.com/Kotlin/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. ```kotlin // UserRepository.kt with error handling import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class UserRepository(private val userDao: UserDao) { suspend fun createUser(email: String, displayName: String): Result { 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` 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](/technologies/android/interview-questions/android-room-database) to reinforce the concepts. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/android/room-database-android-2026-migrations-relations-coroutines