Free Room & Persistence Interview Questions & Answers
Room, migrations, relations, DataStore, and offline storage.
All 50 questions and detailed answers are free. No account or sign-in required.
What are the three core components you must define to use Room, and what does each represent?
Room is built around three annotations: the @Entity class maps to a table and each of its fields to a column, the @Dao interface declares the methods that read and write those rows through SQL, and the @Database abstract class ties the entities and DAOs together and serves as the main access point that Room generates an implementation for. You never write the implementation yourself; the annotation processor generates it at compile time, which is also why Room can validate your SQL against the schema before the app ever runs. Getting the database instance is done through Room.databaseBuilder or Room.inMemoryDatabaseBuilder.
How do you declare an auto-incrementing primary key in a Room entity, and what value should you assign when inserting a new row?
You annotate the field with @PrimaryKey(autoGenerate = true), typically on a Long id, and when inserting a brand-new object you set that id to 0 (or its default), which signals Room to let SQLite assign the next value. A common mistake is treating an Int id as fine, but autoGenerate relies on SQLite's rowid mechanism and works best with Long to avoid overflow on large tables. Another subtlety is that autoGenerate = true adds AUTOINCREMENT semantics, which prevents reuse of deleted ids but has a small performance and storage cost compared to the default rowid behavior you get without it.
What is the difference between calling apply() and commit() on SharedPreferences?
commit() writes the changes to disk synchronously on the calling thread and returns a boolean indicating success, while apply() updates the in-memory copy immediately and schedules the disk write asynchronously, returning nothing. Because commit() performs blocking I/O, calling it on the main thread can cause jank or ANRs, so apply() is generally preferred for fire-and-forget writes. The gotcha is that apply()'s asynchronous disk write can still block the main thread indirectly: pending apply() operations are flushed during activity or service lifecycle transitions, so a burst of apply() calls does not make the underlying I/O free, it just defers it.
Why is SharedPreferences considered problematic, and what is the modern recommended replacement?
SharedPreferences has no first-class asynchronous read API, so the initial load of the file happens on whatever thread first touches it and can block the main thread, it offers no transactional consistency across keys, and it surfaces errors only through runtime exceptions or silent failures. Jetpack DataStore is the modern replacement: it exposes data as a Kotlin Flow, performs all I/O on a background dispatcher through coroutines, and provides transactional updates. There are two flavors, Preferences DataStore which stores key-value pairs like SharedPreferences, and Proto DataStore which stores strongly typed objects defined by a protobuf schema.
On which thread does a suspend DAO function execute its database work, and why does that matter?
When you mark a DAO function as suspend, Room automatically dispatches the actual query execution onto a background executor from its own query dispatcher, so it never runs the disk I/O on the thread that called it even if that is the main thread. This is why you can safely call a suspend DAO method from a coroutine on Dispatchers.Main without wrapping it in withContext(Dispatchers.IO) yourself. The trap is assuming you still need that manual dispatcher switch: doing so is redundant for Room, and more importantly, blocking synchronous DAO calls that are not suspend and not returning Flow will throw an IllegalStateException if invoked on the main thread because Room enforces that guard.
Why should a Room database instance be a singleton, and what goes wrong if you create multiple instances?
Creating a RoomDatabase is expensive because it opens the SQLite connection and sets up connection pools and the invalidation tracker, so you should build it once and reuse that single instance for the app's lifetime, usually via a dependency injection scope or a companion-object double-checked lock. If you accidentally create multiple instances pointing at the same file, each has its own invalidation tracker, so a write through one instance will not notify Flow observers registered through the other, leading to stale UI that never updates. You also risk lock contention and wasted memory, which is why frameworks like Hilt provide the database as an @Singleton.
How do you store an enum or a Date in a Room column, given that SQLite has no such types?
You provide a TypeConverter class with methods annotated @TypeConverter that convert the unsupported type to and from a type SQLite understands, such as turning an enum into its name String or ordinal Int, and a Date into a Long epoch millisecond value, then register the converter on the database or entity with @TypeConverters. The safer choice for enums is to store the name rather than the ordinal, because ordinals silently break if you reorder or insert enum constants later, whereas names survive reordering. Room does not persist enums automatically, so forgetting the converter produces a compile-time error rather than a runtime surprise, which is a helpful guardrail.
What does @Embedded do, and when would you reach for it?
@Embedded lets you flatten the fields of a nested object directly into the parent entity's table as additional columns rather than creating a separate table, so an Address object embedded in a User entity becomes street, city, and zip columns on the users table. It is purely a schema and mapping convenience; there is no relationship or join involved. If two embedded objects share field names you must supply a prefix to disambiguate the resulting column names. It is also commonly used with DAO query result classes to group columns from a join into a sub-object without needing @Relation.
What is the practical difference between Preferences DataStore and Proto DataStore?
Preferences DataStore stores loosely typed key-value pairs and requires no schema, so it is a near drop-in conceptual replacement for SharedPreferences but offers no compile-time type safety, meaning a typo in a key or a wrong type read returns null or a default silently. Proto DataStore stores a single strongly typed object defined by a protobuf schema, giving you compile-time type safety, default values, and a clear structure at the cost of writing a .proto file and a serializer. You choose Proto when the data has real structure or invariants you want enforced, and Preferences when you just need a few flat settings.
Why is DataStore described as a single source of truth, and how does that change how you read a value?
DataStore exposes its contents as a Flow that emits the current value and then re-emits whenever the data changes, so instead of imperatively reading a snapshot you collect the Flow and react to updates, which keeps your UI automatically in sync with the persisted state. This means there is no getter that returns a plain value on demand in the natural API; you either collect the Flow or use first() to grab a one-shot value in a coroutine. Treating it as a source of truth means you write through DataStore and observe through DataStore rather than caching the value elsewhere, avoiding the drift bugs that plague SharedPreferences-based state.
When a Room DAO query returns a Flow, how granular is the change notification that triggers re-emission?
Room's invalidation tracker works at table granularity, not row granularity, so a Flow query that reads from a table re-emits whenever any row in any table referenced by the query is inserted, updated, or deleted, even if the specific rows your query returns did not change. This causes over-emission: a Flow selecting one user by id will re-run and re-emit after an unrelated user is updated in the same table. The result is correct but potentially wasteful, running the query more often than strictly necessary, which matters for expensive queries or high-write-rate tables.
Given Room's table-level invalidation causing over-emission, how do you avoid redundant downstream work?
Because Room re-runs the query and re-emits even when the actual result rows are identical, you apply distinctUntilChanged() on the Flow so that consecutive equal results are filtered out and only genuine changes propagate to your UI or business logic. This relies on your result type having a correct equals implementation, which data classes provide automatically. It is worth stressing that distinctUntilChanged does not stop Room from re-executing the query, it only suppresses duplicate emissions downstream; if the query itself is expensive you may additionally need to narrow what tables it touches or cache upstream.
Why should a DAO method that returns an entity together with its @Relation be annotated with @Transaction?
Room satisfies a @Relation by running multiple queries under the hood, one for the parent rows and one or more for the related child rows, and without @Transaction those queries execute separately so another write could modify the data between them, producing an inconsistent combined result. Annotating the method with @Transaction makes all those underlying queries run inside a single database transaction, guaranteeing a consistent snapshot. Room actually emits a compile-time warning for relation queries missing @Transaction, and for Flow or LiveData relation queries it also ensures the observer is notified correctly, so it is not merely a nicety.
How do you model a one-to-many relationship in Room, and what does the query result class look like?
You define the two entities normally, giving the child entity a foreign key column that references the parent's primary key, then create a plain data class that holds the parent via @Embedded and the list of children via a @Relation whose parentColumn and entityColumn name the join columns. The DAO returns this combined class from a query that selects only the parent rows, and Room fills in the children automatically. You should annotate that DAO method with @Transaction because Room runs a separate child query per parent, and the whole read should be atomic and consistent.
How do you model a many-to-many relationship in Room, and what is the role of the junction?
A many-to-many relationship, such as students and courses, is modeled with a third cross-reference entity holding pairs of the two foreign keys, and the @Relation is declared with an associateBy = Junction(...) parameter that names that cross-reference table plus the two join columns. Room then resolves the relation by joining through the junction table so you get, for example, a Course with its list of Students. The junction entity typically has a composite primary key of the two foreign keys, and adding indices on each foreign key column is important because the join otherwise scans, which degrades performance as the tables grow.
What is the N+1 problem in the context of Room relations, and does @Relation cause it?
The N+1 problem is issuing one query to fetch N parent rows and then N additional queries, one per parent, to fetch each parent's children, resulting in N+1 round trips. Room's @Relation does not literally do one query per parent; it batches the child fetch into a small number of queries using an IN clause over the collected parent keys, so it is closer to a constant number of queries. However, you can still create real N+1 patterns yourself by looping over parents in code and calling a child DAO query inside the loop, so the guidance is to lean on @Relation or an explicit JOIN rather than manual per-row queries.
What does an index do for a Room query, and what is a covering index?
Declaring an index on a column, via @Entity(indices = ...) or @ColumnInfo(index = true), lets SQLite look up matching rows through a B-tree rather than scanning the whole table, dramatically speeding WHERE, JOIN, and ORDER BY on that column at the cost of extra storage and slower writes. A covering index is one that includes every column a particular query needs, so SQLite can answer the query entirely from the index without touching the table rows at all, avoiding the extra lookup step. The tradeoff is that indices are not free: over-indexing slows inserts and updates and bloats the database, so you index the columns your real queries filter and sort on.
What happens if you enable foreign keys with onDelete = CASCADE versus relying on manual cleanup?
With a foreign key declared and onDelete = ForeignKey.CASCADE, deleting a parent row causes SQLite to automatically delete the dependent child rows in the same operation, keeping referential integrity without extra code. If instead you rely on manual cleanup, you must remember to delete children yourself in a transaction, and forgetting leaves orphaned rows that violate integrity and can crash later inserts. The subtlety is that foreign key enforcement must be on, which Room enables per its schema, and that cascading deletes still fire Room's invalidation for the child table, so Flow observers of the children update correctly.
You write a row and immediately observe a Flow query; are you guaranteed to see your write?
Yes, provided the write and the observation go through the same singleton database instance, because Room's invalidation tracker records the tables changed by the write and notifies the Flow, which then re-runs and emits the updated data. The consistency guarantee comes from SQLite transactions plus Room's tracker, not from timing, so you do not need to add artificial delays. The failure mode is using two different database instances or a raw SQLite connection outside Room for the write, in which case Room's tracker never learns of the change and the Flow appears stale, which is the same reason the singleton rule matters.
What is @Upsert in Room, and how does it differ from @Insert with a conflict strategy?
@Upsert, added in Room 2.5, performs an insert if the row does not exist and an update if it does, based on the primary key, giving you insert-or-update semantics in one DAO method. This differs from @Insert(onConflict = REPLACE), which on a key conflict deletes the existing row and inserts a fresh one; that delete-then-insert can fire foreign key cascades and reset auto-generated ids and other columns not present in the object, whereas @Upsert updates in place and preserves the existing row's identity. So @Upsert is safer when you only want to overwrite provided fields without triggering cascade side effects.
Why does fallbackToDestructiveMigration cause data loss, and when is it acceptable?
fallbackToDestructiveMigration tells Room that when it cannot find a Migration path for a schema version change, it should drop all the existing tables and recreate the schema from scratch, which permanently deletes every row the user had stored. It is acceptable only for data that is purely a rebuildable cache, such as content re-fetchable from the network, where losing it is harmless, or during early development before you ship. For any user-generated or otherwise irreplaceable data it is dangerous, and shipping it as a blanket safety net is a common bug that silently wipes users' data whenever a developer bumps the version without writing a migration.
What must you provide to migrate a Room database from version 1 to version 2, and what is the risk if you skip it?
You supply a Migration object whose from and to versions bracket the change and whose migrate method executes the raw SQL to transform the schema, such as ALTER TABLE to add a column, then register it with addMigrations on the database builder. If you bump the version number but forget to add a matching Migration, Room throws an IllegalStateException at runtime when it opens the database on an upgraded install, unless you have destructive fallback enabled, in which case it silently wipes the data instead. The migrate method runs inside a transaction, and you must write SQL that exactly reproduces the schema Room expects for the new version or validation fails.
What are autoMigrations, and when do you still need an AutoMigrationSpec?
autoMigrations, configured via the autoMigrations array in @Database, let Room generate the migration SQL for you by diffing the exported schema JSON of the two versions, which handles straightforward changes like adding a column or a table without hand-written SQL. You still need an AutoMigrationSpec when the change is ambiguous to a diff, such as renaming a column or table or deleting one, because Room cannot infer intent; you annotate a spec class with @RenameColumn or @DeleteTable to disambiguate. Auto-migrations also require that you enable schema export, since they compute the diff from the exported JSON schema files.
Why must you export the Room schema, and how does it relate to testing migrations?
Enabling schema export, through the room.schemaLocation annotation processor option, writes a JSON description of each database version to a directory, and these files are the ground truth that both auto-migrations and migration tests rely on. For testing you use MigrationTestHelper, which reads those exported JSON schemas to create a database at an old version, applies your migrations, and validates that the resulting schema matches the newer exported schema. Without exported schemas you cannot write reliable migration tests and auto-migrations cannot compute their diffs, so exporting is effectively mandatory once you have more than one version, and the schema files should be committed to version control.
How does MigrationTestHelper let you verify a migration actually preserves data?
MigrationTestHelper creates a real database file at the starting schema version using the exported JSON, lets you insert known rows with raw SQL, then runs runMigrationsAndValidate with your Migration objects to the target version, which both applies the migrations and asserts the resulting schema matches the exported target schema. After that you can reopen the database through Room and query it to confirm the old rows survived and were transformed correctly. The key value is that it catches two distinct failure classes: schema mismatches, where your migration SQL diverges from what Room expects, and data loss, where a column-copy step was wrong, which a schema-only check would miss.
Are the statements inside a Room @Transaction method atomic, and what happens on an exception?
Yes, a method annotated @Transaction executes all of its database operations inside a single SQLite transaction, so either every statement commits or, if any of them throws an exception, the whole transaction rolls back and none of the changes persist, preserving all-or-nothing atomicity. This is what makes it correct to, say, deduct from one account and credit another in one @Transaction method without risking a partial state. A subtle point is that the rollback only covers database work; any in-memory side effects or external calls you made inside the method are not undone, so you should keep transaction bodies focused on database operations.
How do you prepopulate a Room database from a bundled asset file, and what is a common pitfall?
You call createFromAsset on the database builder, passing the path to a prepackaged .db file in the assets folder, and Room copies it into place the first time the database is created rather than running an empty schema. The prepackaged database's schema must exactly match the schema Room expects for the current version, including the special room_master_table identity hash, otherwise Room rejects it, so the usual approach is to generate the asset from an exported schema. A common pitfall is that createFromAsset only applies on first creation, so if you later change the schema you must supply migrations or a matching new asset, and destructive fallback plus an asset can reset to the bundled data unexpectedly.
What is Room FTS4 support, and what tradeoffs come with using an @Fts4 entity?
Annotating an entity with @Fts4 backs it with SQLite's full-text search virtual table, enabling fast prefix and token MATCH queries over text columns that would otherwise require slow LIKE scans, which is ideal for search features. The tradeoffs are that FTS tables do not support a normal auto-generated primary key beyond the implicit rowid, they consume extra storage for the search index, and they cannot have foreign keys, so you often keep the FTS table as a shadow of a normal content table. You typically pair it with content= to point the FTS table at an external content table, avoiding duplicate storage of the raw text while still getting the search index.
How does Room integrate with Paging 3, and why is returning a PagingSource preferable to loading a full list?
A Room DAO can return a PagingSource directly from a query, and Room generates an implementation that loads pages on demand as the user scrolls, which you feed into a Pager to produce a Flow of PagingData. This is preferable to returning a full List because loading thousands of rows into memory at once is slow and memory-heavy, whereas paging keeps only a window of rows resident. A key detail is that Room's generated PagingSource is aware of the invalidation tracker, so when the underlying table changes the current PagingSource is invalidated and the Pager transparently creates a new one, keeping the paged list consistent with writes.
How do you create an in-memory Room database for tests, and what is the behavioral difference from a file-backed one?
You build it with Room.inMemoryDatabaseBuilder instead of databaseBuilder, which keeps all data in RAM and discards it entirely when the process ends or the database is closed, so each test starts from a clean state without touching disk. This makes tests fast and isolated, and you typically also call allowMainThreadQueries in tests to simplify synchronous assertions, which you would never do in production. The behavioral difference to remember is that an in-memory database does not exercise the file-copy paths like createFromAsset or on-disk corruption handling, and because it is discarded on close you cannot test persistence across app restarts with it.
Why is storing large blobs like images directly in a Room column an antipattern, and what is the recommended alternative?
Storing large binary blobs such as full-resolution images or videos in a SQLite column bloats the database file, slows queries because SQLite must read the blob to traverse rows, complicates backups, and can hit the practical row-size and cursor-window limits that cause CursorWindow allocation errors on large reads. The recommended pattern is to write the binary file to internal or external storage or the cache directory and store only its file path or URI as a String in the database. This keeps rows small and queries fast, lets the file system and image loaders handle the bytes efficiently, and sidesteps the cursor window size ceiling entirely.
How do you encrypt a Room database, and what changes in your setup when you adopt SQLCipher?
You encrypt a Room database by supplying a SupportSQLiteOpenHelper.Factory backed by SQLCipher, using SupportFactory with a passphrase, via openHelperFactory on the database builder, which transparently encrypts the database file at rest so it cannot be read without the key. The main new concern becomes key management: you must store the passphrase securely, typically in the Android Keystore, because embedding it in the APK defeats the purpose. You also accept a modest performance overhead from encrypt and decrypt on every page access, and you lose the ability to open the file with standard tooling, so debugging requires a SQLCipher-aware client.
What does VACUUM do to a SQLite or Room database, and why does deleting rows not immediately shrink the file?
When you delete rows, SQLite marks those pages as free and reuses them for future inserts rather than returning the space to the file system, so the file stays the same size even though logical content shrank, which is efficient but can leave a large file after a big purge. VACUUM rebuilds the database into a fresh file, discarding the free pages and defragmenting, which reclaims disk space and can improve locality. In Room you can run VACUUM through a raw query, but it is expensive, requires roughly double the space temporarily, and locks the database, so you run it sparingly, for instance after a large one-time cleanup, not on every launch.
How can Room detect and respond to a corrupted database file?
SQLite can corrupt due to interrupted writes, disk faults, or storage failures, and Room lets you register a callback for this via setJournalMode considerations plus a PrepackagedDatabaseCallback or, more directly, by supplying a corruption handler through the support open helper, where the default behavior deletes the corrupt file so the database can be recreated. You can customize onCorruption to attempt a backup or logging before deletion. The practical takeaway is that corruption manifests as SQLiteDatabaseCorruptException, and relying on the default deletion means data loss, so for important data you either keep a re-syncable source of truth on the server or implement a recovery strategy rather than silently wiping.
If a DAO query returns a plain non-suspend, non-Flow value, what does Room require about the calling thread and why?
For a synchronous DAO method that returns a plain value directly, Room by default refuses to run it on the main thread and throws an IllegalStateException, because the query performs blocking disk I/O that would risk an ANR on the UI thread. You are expected to call such methods from a background thread yourself, or better, convert them to suspend functions or Flow-returning queries so Room handles threading. The escape hatch allowMainThreadQueries exists but is intended for tests or rare cases; using it in production reintroduces exactly the jank the guard was protecting against, so it is a code smell outside of test setups.
You have a screen showing a user and their orders; how do you decide between one @Relation query and separate DAO calls?
If you need the parent and its children together as a consistent unit, a single @Relation query annotated with @Transaction is the right choice because Room fetches both under one transaction and batches the child query, avoiding both inconsistency and N+1 round trips. You would use separate DAO calls only when the children are fetched lazily or independently, for example paging the orders separately or fetching them on a user action, but then you accept that the two reads are not atomic and could observe interleaved writes. The decision hinges on whether atomicity and a single reactive stream matter; if the UI treats them as one aggregate, prefer the relation query.
When observing a Room Flow that joins several tables, why might you see emissions that seem unrelated to your data, and how do you reason about it?
Room's invalidation tracker marks a Flow as dirty whenever any table the query reads is modified, and a multi-table join reads all of those tables, so a write to any one of them, even to rows your join excludes, triggers a re-run and emission. You reason about it by recognizing invalidation is table-scoped, so the surface area for over-emission grows with the number of tables joined. Mitigations include applying distinctUntilChanged to drop identical results, narrowing the query to touch fewer tables, or splitting into smaller queries combined with combine, accepting that Room deliberately trades precision for the simplicity and correctness of table-level tracking.
How do updates to a Proto DataStore stay consistent under concurrent writers, and what does the updateData API guarantee?
Proto DataStore's updateData takes a suspending transform that receives the current value and returns the new one, and DataStore serializes these transforms so concurrent updates are applied one at a time against the latest state rather than racing, and it only commits to disk after the transform completes successfully. This gives you read-modify-write atomicity without manual locking, which SharedPreferences cannot offer. The guarantee is that within a single DataStore instance in a process, updates are consistent and durable once updateData returns; the caveat is you must use one DataStore instance per file across the process, since multiple instances on the same file break that serialization and can corrupt data.
Why can reading a value from DataStore throw, and how should you handle it in the Flow?
DataStore reads flow through a Flow that can emit an IOException if the file cannot be read, or for Proto DataStore a CorruptionException if deserialization fails, so unlike SharedPreferences the error is surfaced explicitly rather than swallowed. You handle it by applying catch on the Flow, where you can emit an empty or default value on IOException while rethrowing anything unexpected, and for corruption you provide a corruptionHandler in the serializer that produces a valid replacement value. This explicit error surface is a feature, not a burden, because it forces you to define recovery behavior instead of leaving corruption to silently return defaults as SharedPreferences would.
A colleague wraps every suspend DAO call in withContext(Dispatchers.IO); is that correct, and what is the deeper issue?
It is unnecessary because Room already moves suspend DAO execution to its own background query dispatcher, so the extra withContext(Dispatchers.IO) just adds a redundant thread hop without improving safety. The deeper issue is that this habit signals a misunderstanding that can cause real bugs elsewhere: if the colleague also runs multiple DAO calls that should be one transaction on separate dispatchers, they lose atomicity, and Room's suspending transaction API, withTransaction, must be used to keep a multi-call transaction on the correct single connection. So the fix is not just removing the redundant wrapper but understanding that Room manages both threading and transactional connection affinity for suspend functions.
Why does Room require a suspending withTransaction block rather than a plain runBlocking transaction for multiple suspend DAO calls?
SQLite transactions are tied to a specific connection and thread, and suspend functions can resume on different threads, so if you naively grouped several suspend DAO calls they might not share the same transaction and connection, breaking atomicity. Room's database.withTransaction suspending function solves this by confining all the suspend DAO calls inside its lambda to a single dedicated transaction thread through a special coroutine context element, ensuring they commit or roll back together. Using runBlocking or manual beginTransaction with suspend calls risks deadlocks or connection mismatches, which is exactly why the dedicated withTransaction API exists for coroutine-based transactional work.
How does Room decide which tables to observe for a raw or complex query, and what breaks that detection?
Room parses the SQL of your query at compile time to determine which tables it reads and registers those tables with the invalidation tracker so the Flow re-emits on their changes, which works transparently for normal @Query methods. Detection breaks with @RawQuery using SupportSQLiteQuery, where Room cannot statically analyze the SQL, so you must explicitly declare the observed tables via the observedEntities parameter, otherwise the Flow never re-emits on relevant changes. Similarly, querying a view or using functions that touch tables Room does not associate can cause missed invalidation, so complex reactive queries need you to verify that the intended tables are actually being tracked.
What is the difference between storing an enum as its ordinal versus its name in a TypeConverter, and which is safer long term?
Storing the ordinal persists the enum constant's integer position, which is compact but fragile: if a future release reorders the constants or inserts a new one in the middle, every stored ordinal now maps to a different constant, silently corrupting the meaning of existing rows with no error. Storing the name persists the constant's identifier String, which is slightly larger but stable across reordering, and an unknown name at least fails loudly if a constant is removed. The safer long-term choice is the name for exactly this reason, and if you must use ordinals you should treat the enum order as a permanent schema contract that can only be appended to.
When migrating a Room schema by adding a NOT NULL column, why can the migration fail on existing rows, and how do you fix it?
Adding a column declared NOT NULL without a default value fails or leaves Room's schema validation unhappy because the existing rows have no value for the new column, and SQLite cannot assign one, so the ALTER TABLE either errors or produces a schema that does not match what Room expects for the new version. The fix is to add the column with a DEFAULT clause in your migration SQL, such as NOT NULL DEFAULT 0, so existing rows get a valid value, and to ensure the entity's @ColumnInfo defaultValue matches, otherwise Room's identity-hash validation reports a mismatch between the migrated schema and the exported schema for that version.
How does Room's PagingSource stay in sync with database writes, and what happens to the current page window when a write occurs?
Room generates a PagingSource wired into the invalidation tracker, so when any observed table changes the current PagingSource is invalidated, which signals Paging to discard it and construct a fresh PagingSource that reloads from the anchor position near the user's current scroll. This means a write does not surgically patch the visible window; it triggers a reload of the affected pages so the list reflects the new data, with Paging preserving scroll position via the anchor. The subtlety is that frequent writes to the observed tables can cause repeated invalidation and reloads, so for very write-heavy tables you may need to scope the query or debounce to avoid thrashing the pager.
Why might a covering index dramatically speed up a Room query while a normal index barely helps, for the same column?
A normal single-column index speeds finding the matching rows, but if the query also selects columns not in that index, SQLite must still perform a second lookup into the table's main B-tree for each matched row to fetch those columns, which dominates the cost when many rows match. A covering index includes all columns the query needs, either in the indexed columns or as included payload, so SQLite answers entirely from the index without any table lookups, eliminating that second step. Thus for a query that returns few columns over many matches, widening the index to cover exactly those columns can turn many random table reads into a single sequential index scan, a large win the plain index cannot deliver.
In a many-to-many relation resolved through a junction, why can queries become slow, and what indexing strategy fixes it?
When Room joins parent to child through a junction table, it matches on the junction's two foreign key columns, and if those columns are not indexed, each join step forces a full scan of the junction table, so query time grows with the product of the table sizes and degrades sharply as data accumulates. The fix is to declare an index on each foreign key column of the junction entity, ideally so that lookups in both directions, parent-to-child and child-to-parent, are index-backed. A composite primary key on the pair helps one direction, so you typically add an explicit second index for the other column to make both traversal directions efficient.
How does @Upsert interact with foreign key cascades differently than @Insert(onConflict = REPLACE), and why does it matter?
@Insert with REPLACE resolves a conflict by deleting the existing row and inserting a new one, and that delete can trigger ON DELETE CASCADE on child tables, silently wiping related rows and also resetting the parent's auto-generated id, which is a nasty surprise when you only meant to update a couple of fields. @Upsert instead issues a real UPDATE on conflict, so no delete occurs, cascades do not fire, and the row's identity and untouched columns are preserved. This matters whenever the entity is a foreign key target with dependent rows, because choosing REPLACE there can cascade-delete children that should have survived, making @Upsert the correct tool for update-or-insert on such entities.
You need offline search over thousands of records with reactive UI updates; how do you combine FTS4, external content, and Flow correctly?
You back the searchable text with an @Fts4 entity declared with contentEntity pointing at your normal content table so the FTS index does not duplicate the raw text, then expose a DAO @Query using MATCH that returns a Flow of results for reactive updates. The catch is that Room's invalidation for an external-content FTS table depends on keeping the FTS index synchronized with the content table, which requires triggers or manual rebuild on content changes, and the Flow must observe the right tables, so you verify that inserts into the content table actually invalidate the FTS-backed query. You also apply distinctUntilChanged because searches over shared tables over-emit, and you rank results with FTS ordering functions for relevance.
You must migrate an encrypted SQLCipher Room database across a schema change while rotating the passphrase; what is the correct sequence and pitfalls?
You open the database with the old passphrase through the SQLCipher SupportFactory so Room can run its normal Migration objects against the decrypted-in-memory pages, applying the schema change first while the file is still keyed with the old passphrase, then perform the key rotation separately using SQLCipher's rekey operation, PRAGMA rekey, which re-encrypts every page with the new key. The pitfalls are that Room's schema migrations and SQLCipher's rekey are independent concerns that must not be interleaved incorrectly: rekey rewrites the whole file and must complete atomically, so you do it outside Room's per-version migrate steps, and you must ensure the new passphrase is stored in the Keystore before you discard the old one, or an interrupted rotation leaves an unopenable database. You also keep schema export on so the migrated schema still validates against the exported target.
Practice all Room & Persistence questions interactively
Search, filter, and mark questions complete in the free Preparation Path. You can start immediately without an account.
More Android interview topics
- Kotlin interview questions
- Coroutines & Flow interview questions
- Jetpack Compose interview questions
- Android Architecture interview questions
- Android Framework interview questions
- Testing interview questions
- Dependency Injection interview questions
- Networking interview questions
- Android System Design interview questions
- Android Tools interview questions