Introduction
When you need to persist data on an Android device, SQLite is the standard choice—but writing raw SQL queries and managing cursors quickly becomes tedious and error-prone. Room, Google's persistence library built on top of SQLite, gives you type-safe database access through Kotlin data classes and coroutines. This Room database tutorial: a practical guide for developers shows you how to structure local storage the way production teams do across e-commerce, travel, and SaaS Android apps.
We'll walk through the three core components—Entity, DAO, and Database—and show you how to leverage Kotlin Flows to keep your UI in sync whenever your data changes.
Entity — A data class mapped to a table
An Entity is simply a Kotlin data class annotated with @Entity. Room uses it to create and manage your database table schema. Each property becomes a column, and you define primary keys, indices, and foreign key constraints right on the class.
Let's say you're building an e-commerce app and need to store products locally:
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "products")
data class ProductEntity(
@PrimaryKey
val id: String,
val name: String,
val price: Double,
val sku: String,
val imageUrl: String?,
val categoryId: String,
val inStock: Boolean = true,
val lastUpdated: Long = System.currentTimeMillis()
)In a real-world scenario, you'd likely sync this with a backend API. The lastUpdated timestamp helps you determine when to refresh from the server. The imageUrl is nullable because some products might not have images yet.
Room also supports relationships. If your products belong to categories, you can create a separate Category entity and link them:
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey
val id: String,
val name: String,
val description: String?
)For queries that need both products and their categories, you'd create a data class with an embedded relationship—but we'll focus on single-entity queries here to keep things concrete.
DAO — Queries as annotated functions
A DAO (Data Access Object) is an interface or abstract class that defines all the queries you need. Instead of writing raw SQL strings scattered throughout your code, you use annotations like @Query, @Insert, @Update, and @Upsert. Room generates the implementation at compile time.
Here's a practical DAO for our product storage:
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface ProductDao {
@Query("SELECT * FROM products ORDER BY name")
fun observeAll(): Flow>
@Query("SELECT * FROM products WHERE categoryId = :categoryId ORDER BY name")
fun observeByCategory(categoryId: String): Flow>
@Query("SELECT * FROM products WHERE id = :productId LIMIT 1")
suspend fun getById(productId: String): ProductEntity?
@Query("SELECT COUNT(*) FROM products")
suspend fun count(): Int
@Upsert
suspend fun upsert(items: List)
@Query("DELETE FROM products WHERE categoryId = :categoryId")
suspend fun deleteByCategoryId(categoryId: String)
}Notice a few key patterns:
- Flow for observing. Methods like
observeAll()returnFlow<List<ProductEntity>>. Room automatically re-emits whenever the underlying table changes. Your UI can collect this flow and update in real time. - Suspend for single operations. Methods like
getById()are suspend functions. They're fast enough that you don't need reactive updates, but they still run on the database dispatcher to avoid blocking the main thread. - Upsert for efficiency. The
@Upsertannotation handles insert-or-update in a single operation—perfect when syncing from an API and you don't know if each item already exists locally. - Parameterized queries. Use
:paramNamein your SQL and pass it as a function parameter. Room handles escaping automatically.
In a typical flow, you'd fetch products from your backend API, then call productDao.upsert(freshProducts) to sync them to the device. Any screens observing productDao.observeAll() update instantly.
Database — Abstract class extending RoomDatabase
The Database class is your entry point. It inherits from RoomDatabase and declares which entities and DAOs it manages. You typically create it as a singleton to ensure a single database instance across your entire app.
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [ProductEntity::class, CategoryEntity::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun productDao(): ProductDao
abstract fun categoryDao(): CategoryDao
companion object {
private var instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.build()
.also { instance = it }
}
}
}
}If you're using dependency injection (which we recommend for production apps), use Hilt or Koin instead of the companion object pattern:
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
fun provideDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app_database"
).build()
}
@Provides
fun provideProductDao(db: AppDatabase): ProductDao = db.productDao()
}The version parameter matters. When you modify your schema (add a column, change a type), you increment the version and provide a migration. Migrations are how you evolve your database without losing user data:
val migration1To2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE products ADD COLUMN discount REAL DEFAULT 0.0")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(migration1To2)
.build()Flows — Room emits new data when tables change
One of Room's most powerful features is automatic change notifications. When you return Flow from a query, Room tracks which tables that query touches. Whenever those tables are modified—via insert, update, or delete—Room re-runs the query and emits the new result to all collectors.
This is how you keep your UI synchronized without manual refresh logic:
// In your ViewModel or Repository
class ProductRepository(private val productDao: ProductDao) {
val products: Flow> = productDao.observeAll()
.map { items ->
items.sortedByDescending { it.lastUpdated }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList()
)
suspend fun syncProducts(freshData: List) {
productDao.upsert(freshData)
// No need to manually notify observers—Room does it automatically
}
}
// In your Composable
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
val products by viewModel.products.collectAsState(initial = emptyList())
LazyColumn {
items(products) { product ->
ProductCard(product)
}
}
}The stateIn operator converts the cold flow into a warm state flow, caching the latest value. This prevents unnecessary re-compositions and database queries when your screen rotates or the view hierarchy rebuilds.
Room's flow integration is especially valuable in real-time scenarios. Imagine a shopping cart: when you upsert cart items, any screen observing cartDao.observeCart() updates instantly without polling or manual refresh buttons.
Common mistakes
Calling suspend functions on the main thread. Always collect flows and call suspend functions from a coroutine context. Don't do this:
// Wrong: blocking on main thread
val product = runBlocking { productDao.getById(id) }
Instead, use the proper coroutine scope from your ViewModel:
// Right: runs on IO dispatcher automatically
viewModelScope.launch {
val product = productDao.getById(id)
}Forgetting to handle nullable results. Single-row queries often return null if no match is found. Always use optional types and handle the null case in your UI logic.
Creating multiple database instances. If you forget the singleton pattern or dependency injection, you might accidentally create separate database connections. This wastes memory and can cause data inconsistencies. Always use a single, app-wide instance.
Ignoring schema versions. When you deploy app updates with schema changes and skip migrations, your app crashes on users' devices. Always increment the version and provide migration paths.
Key takeaways
- Entities are data classes that map directly to database tables; annotate them with
@Entityand define your schema through properties. - DAOs use
@Query,@Upsert, and other annotations to define type-safe queries; Room generates implementations at compile time. - Return
Flowfrom queries to observe data changes automatically; Room re-emits whenever the underlying tables change. - Suspend functions run on the database dispatcher automatically; never block the main thread with
runBlocking. - Always use a singleton database instance via dependency injection or a companion object to avoid memory waste and data inconsistencies.
- Manage schema evolution through migrations; increment the database version and provide migration logic when your schema changes.






Comments
Loading comments…