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 Flow return 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 = true writes the schema JSON into version control, which is what allows a migration test to verify old and new schemas against each other.
  • @Upsert replaces the insert-or-update dance. Inside a @Transaction it 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>>
DecisionRoomDataStoreSharedPreferences
Structured, queryable dataYesNoNo
Simple key-value settingsOverkillYes, typedYes, untyped
Type safetyCompile-timeCompile-time with proto or preferencesNone
Change observationFlow queriesFlowListener, easy to leak
TransactionsYesAtomic per updateNo
Large binary payloadBlob or a fileNoNo
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.inMemoryDatabaseBuilder in 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.

Networking with Retrofit, coroutines and repositories Testing Android apps

Last refreshed 2026-09-18.