Local persistence with Room and DataStore
Entities, DAOs and Flow queries, migrations as first-class code, transactions, and choosing between Room and DataStore.
Room basics
@Entity(tableName = "books")
data class BookEntity(
@PrimaryKey val id: Long,
val title: String,
val authorId: Long,
val updatedAt: Long,
)
@Dao
interface BookDao {
@Query("select * from books order by title")
fun observeAll(): Flow<List<BookEntity>>
@Query("select * from books where id = :id")
suspend fun byId(id: Long): BookEntity?
@Upsert
suspend fun upsert(books: List<BookEntity>)
@Transaction
suspend fun replaceAll(books: List<BookEntity>) {
clear()
upsert(books)
}
@Query("delete from books")
suspend fun clear()
}
@Database(entities = [BookEntity::class], version = 3, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun bookDao(): BookDao
}- A
Flowreturn type makes the query observable: Room re-runs it when the underlying tables change and emits a new list, so no manual refresh is needed. exportSchema = truewrites the schema JSON into version control, which is what allows a migration test to verify old and new schemas against each other.@Upsertreplaces the insert-or-update dance. Inside a@Transactionit is atomic, so a list replacement cannot leave the table empty.- Room verifies SQL at compile time. A typo in a column name is a build error, which is the main reason to prefer it over raw SQLite.
⚠️
A schema change without a migration throws
IllegalStateException at runtime for existing users, while working perfectly on a clean install. Every entity change needs a migration and a test that runs it.Migrations and relations
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("alter table books add column isbn text")
db.execSQL("create index if not exists index_books_authorId on books (authorId)")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_2_3)
.fallbackToDestructiveMigrationOnDowngrade() // explicit, not accidental
.build()
// a relation loaded in one query
data class AuthorWithBooks(
@Embedded val author: AuthorEntity,
@Relation(parentColumn = "id", entityColumn = "authorId")
val books: List<BookEntity>,
)
@Transaction
@Query("select * from authors")
fun observeAuthorsWithBooks(): Flow<List<AuthorWithBooks>>| Decision | Room | DataStore | SharedPreferences |
|---|---|---|---|
| Structured, queryable data | Yes | No | No |
| Simple key-value settings | Overkill | Yes, typed | Yes, untyped |
| Type safety | Compile-time | Compile-time with proto or preferences | None |
| Change observation | Flow queries | Flow | Listener, easy to leak |
| Transactions | Yes | Atomic per update | No |
| Large binary payload | Blob or a file | No | No |
val Context.settings: DataStore<Settings> =
dataStore("settings", serializer = SettingsSerializer)
// a DataStore update is atomic and exposes the change as a flow
suspend fun setDarkMode(enabled: Boolean) {
context.settings.updateData { it.copy(darkMode = enabled) }
}
val darkMode: Flow<Boolean> = context.settings.data.map { it.darkMode }Use DataStore for preferences: it is asynchronous, transactional and typed. SharedPreferences applies changes to disk synchronously on the calling thread, has no type safety, and is easy to read before a write lands.
Working with Room day to day
- Never store a whole API response as a blob to avoid modelling it. You lose querying and the ability to migrate a field; store columns and add a JSON column for the genuinely variable part.
- Keep the database class abstract and expose DAOs. The database itself is a container, and putting query logic in it makes the code harder to test.
- Index foreign keys and any column used in a WHERE clause of a hot query:
@Entity(indices = [Index("authorId")]). - Do not open a transaction around a network call. Room transactions hold a SQLite write lock, and the whole point of Room over raw SQLite is to keep this manageable.
- Run
Room.inMemoryDatabaseBuilderin tests: it runs the real SQL against the real engine, fast and isolated. - Watch for the main-thread query:
allowMainThreadQueries()is a debugging aid, and shipping it turns every slow query into a dropped frame or an ANR.
// an instrumented migration test: create version 2, migrate, verify
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
)
@Test
fun migrate2To3() {
helper.createDatabase("test.db", 2).close()
helper.runMigrationsAndValidate("test.db", 3, true, MIGRATION_2_3)
}
}FAQ
Room or raw SQLite?
Room for application data: compile-time SQL checking, coroutine and Flow support, and a migration framework. Raw SQLite when you need a feature Room does not expose, or you are writing a library that cannot depend on the AndroidX stack.
How do I handle a destructive migration?
Only for a cache you can rebuild. Make it explicit per database, document what is lost, and never use it for data the user created.
fallbackToDestructiveMigration is the single most common cause of silent data loss on upgrade.Related
Networking with Retrofit, coroutines and repositories Testing Android apps
Last refreshed 2026-09-18.