androidinterview.com

Android Storage and Database Interview Questions

12 questions

Tier
Difficulty
Level

Showing all 12 questions

SharedPreferences & DataStore

What is the difference between commit() and apply() in SharedPreferences?

Tier: EssentialDifficulty: Easy

commit() writes to disk synchronously and blocks until it's done, apply() updates the in-memory value immediately and writes to disk asynchronously in the background.

sharedPreferences.edit()
    .putString("token", token)
    .apply() // returns immediately, safe to call from the main thread

The practical differences.

  • Return value. commit() returns a Boolean telling you whether the write succeeded. apply() returns nothing, you get no confirmation the disk write finished.
  • Thread safety for the caller. commit() blocks the calling thread until the write completes, which can jank the UI if called on the main thread. apply() returns immediately since the disk write happens off-thread.
  • Ordering guarantee. apply() still guarantees that if you apply() and immediately commit() on the same file, the commit waits for any pending apply() writes to finish first, so reads stay consistent even though the write itself is async.

The rule of thumb is to default to apply(), since almost nothing needs to block on the write finishing, and reach for commit() only in the rare case where you genuinely need the return value or need to guarantee the write landed before the very next line of code runs, like right before the process might be killed.

Read more SharedPreferences.Editor (opens in a new tab)

What is Jetpack DataStore Preferences?

Tier: CommonDifficulty: Medium

Jetpack DataStore Preferences is the modern, coroutine-based replacement for SharedPreferences, storing key-value data asynchronously and exposing it as a Flow instead of a synchronous, main-thread-unsafe API.

val Context.dataStore by preferencesDataStore(name = "settings")
val USERNAME = stringPreferencesKey("username")

suspend fun saveUsername(context: Context, name: String) {
    context.dataStore.edit { prefs -> prefs[USERNAME] = name }
}

val usernameFlow: Flow<String?> = context.dataStore.data
    .map { prefs -> prefs[USERNAME]}

The problems it fixes are specific ones SharedPreferences has had for years.

  • No main-thread trap. SharedPreferences technically has a synchronous getString() you can call from anywhere, including the UI thread, which quietly loads the whole file from disk the first time. DataStore has no synchronous API at all, so that mistake isn't possible.
  • Consistency. SharedPreferences has documented cases of losing writes under process death right after apply(). DataStore's write path is built on Kotlin coroutines and guarantees a consistent, transactional update.
  • Built-in error handling. Reading DataStore returns a Flow you can catch on, corrupted or missing data surfaces as a normal exception in the stream instead of silently returning defaults.
  • Reactive by default. Every read is a Flow, so a screen observing a setting gets a new value automatically the moment it changes, no listener registration required.

There's also Proto DataStore, which stores typed objects defined in a protobuf schema instead of loose key-value pairs, useful when your settings have real structure, but Preferences DataStore is the direct SharedPreferences replacement and the one that comes up in interviews.

Read more DataStore (opens in a new tab)

SQLite & Room

Have you used Room? Explain it.

Tier: EssentialDifficulty: Easy

Room is Jetpack's persistence library, an ORM layer over SQLite that gives you compile-time-checked SQL and generated boilerplate instead of hand-written Cursor code.

It's built from three pieces.

  • Entity. A data class annotated @Entity that maps to a table, one property per column.
  • DAO. An interface annotated @Dao where you declare queries as methods, Room generates the implementation at compile time.
  • Database. An abstract class annotated @Database that ties the entities and DAOs together and is your handle to the actual SQLite file.
@Entity
data class User(@PrimaryKey val id: String, val name: String)

@Dao
interface UserDao {
    @Query("SELECT * FROM User WHERE id = :id")
    suspend fun getUser(id: String): User

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: User)
}

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

What Room actually buys you over plain SQLite. It checks every @Query against your schema at compile time, so a typo in a column name fails the build instead of crashing at runtime. It maps Cursor rows to your data classes automatically. It supports suspend functions and Flow return types natively, so a DAO method can just emit a new list every time the underlying table changes, no manual observer wiring. And it comes with a migration system for versioning the schema across app updates, which is the part people underestimate until they ship a schema change without one and crash on every upgrading user's device.

Read more Save data in a local database using Room (opens in a new tab)

Describe SQLite.

Tier: CommonDifficulty: Easy

SQLite is a lightweight, embedded, relational database engine that ships as a library baked into every Android device, so an app can have full SQL storage without running a separate database server.

A few things make it what it is.

  • Embedded, not client-server. There's no separate database process to talk to. SQLite is a library linked directly into your app's process, and the entire database lives in a single file on disk.
  • Serverless and zero-configuration. There's nothing to install, start, or administer. The file is created the first time you open it, and that's the whole setup.
  • Relational with SQL. You get tables, rows, columns, and standard SQL for queries, joins, and transactions, the same mental model as a full database server, just running locally.
  • ACID-compliant. Transactions are atomic and durable even if the app or the device crashes mid-write, which matters a lot for data you can't afford to corrupt, like a user's saved drafts.
  • Small footprint. The whole engine is a few hundred kilobytes, which is exactly why it fits on a phone in the first place.

On Android you rarely touch SQLite's raw API directly anymore. Room sits on top of it and generates the boilerplate, compile-time-checked queries, and object mapping, while SQLite itself stays the actual engine doing the storage and query execution underneath.

What is an ORM? How does it work?

Tier: CommonDifficulty: Medium

An ORM, object-relational mapper, is a layer that translates between your language's objects and a relational database's tables, so you work with classes and method calls instead of writing raw SQL and manually mapping rows to fields.

It works by tying three things together.

  • A mapping, usually annotations or a config file, that says which class corresponds to which table and which property corresponds to which column.
  • A query layer, that turns your method calls or a query language you write into actual SQL, then runs it against the database.
  • A hydration step, that takes the raw rows the database returns and builds them back into instances of your class, so what you get back is a typed object, not a Cursor you have to read field by field.

On Android, Room is the ORM you actually use. You mark a class @Entity for the table mapping, declare methods on a @Dao interface annotated with @Query, @Insert, and so on for the query layer, and Room generates the code that runs the SQL and maps Cursor rows back into your data class, all checked against your schema at compile time.

The tradeoff worth mentioning in an interview, an ORM removes a lot of repetitive, error-prone code, but it adds a layer of abstraction between you and the actual SQL. For simple CRUD it's a clear win. For a query that needs careful tuning, a complex join with a specific index strategy, you still end up writing and reasoning about the raw SQL yourself, the ORM at that point is just carrying it for you rather than replacing the need to understand it.

What is Write-Ahead Logging (WAL) and why is it used internally in databases?

Tier: CommonDifficulty: Hard

Write-Ahead Logging is a technique where a database writes every change to an append-only log file first, and only later applies those changes to the main database file, instead of writing directly into the database on every transaction.

The sequence looks like this.

  • A transaction's changes are appended to the WAL file, a fast, sequential write.
  • Readers can keep reading the old, consistent state of the main database file while the WAL holds the newer, uncommitted or recently committed changes.
  • Periodically, a checkpoint runs that replays the WAL back into the main database file and clears it.
  • If the app or the device crashes mid-write, the WAL is replayed on next startup to redo committed transactions and discard incomplete ones, which is what keeps the database from ending up in a half-written, corrupted state.

Two reasons this is faster than the older rollback-journal approach SQLite used before WAL. Sequential writes to the log are cheap because the disk head barely has to move, compared to the random writes a direct in-place update would need. And WAL allows one writer and multiple readers to operate at the same time without blocking each other, since readers just look at the last-known-good state while a write is in flight, where the old journal mode had to lock the whole database file for the duration of a write.

On Android this is exactly why you'd call enableWriteAheadLogging() on a SQLiteDatabase, or why Room lets you configure it, a background sync writing to the database shouldn't freeze a screen that's simultaneously trying to read from it.

Storage & Serialization

What is the difference between Serializable and Parcelable? What are the disadvantages of Serializable and which is best in Android?

Tier: EssentialDifficulty: Medium

Parcelable is Android's own interface for passing objects between components, and it's the one you should use, Serializable is the general Java interface and it's noticeably slower and heavier on Android.

// Serializable, simplest to write
data class Developer(val name: String, val age: Int) : Serializable

// Parcelable, the recommended approach
@Parcelize
data class Developer(val name: String, val age: Int) : Parcelable

The disadvantages of Serializable come down to how it works internally.

  • It uses reflection to figure out an object's fields at runtime, which is slow compared to code that already knows the shape of the object at compile time.
  • It creates a lot of temporary objects during the serialization process, which adds memory churn and garbage collection pressure right when you're trying to pass data between an Activity and a Fragment quickly.
  • It has no control over the format, since it's a generic Java mechanism not built with Android's Bundle transport in mind.

Parcelable avoids all of that. It doesn't use reflection, since with @Parcelize the compiler generates the exact read and write code for your fields ahead of time. It creates far fewer temporary objects, and it's built specifically for Android's IPC path through Bundle and Parcel, which is the mechanism Activities and Fragments actually use to pass data.

So the answer to "which is best" is Parcelable, and with the @Parcelize compiler plugin the boilerplate that used to be the main argument for reaching for Serializable instead is gone, you get the annotation and nothing else to write by hand.

Read more Parcelable implementation generator (opens in a new tab)

What are the different ways to store/persist data in an Android app?

Tier: CommonDifficulty: Easy

Android gives you five main options, and picking the right one comes down to how structured the data is and how much of it there is.

  • SharedPreferences or Jetpack DataStore, for small key-value settings, a theme choice, a flag, a token. DataStore is the modern replacement, asynchronous by default and safe off the main thread.
  • Room, backed by SQLite, for structured, relational, queryable data, a list of cached articles, a chat history, anything you'd filter, sort, or join.
  • Internal app-specific storage, context.filesDir, for arbitrary files private to your app, cache files, downloaded assets, nothing the user or other apps need to see.
  • Shared external storage, through MediaStore or the Storage Access Framework, for files meant to be visible to the user or other apps, photos, downloads, documents.
  • A network-backed store, syncing to a remote database through your backend, for data that needs to survive an uninstall or be available across the user's devices.

The rule of thumb for an interview, key-value settings go in DataStore, anything relational or queryable goes in Room, raw files go in app-specific or shared storage depending on who needs to see them, and none of these are mutually exclusive, a typical app uses at least three of the five at once.

Read more Data and file storage overview (opens in a new tab)

Explain Scoped Storage in Android.

Tier: CommonDifficulty: Medium

Scoped storage is the model, enforced by default since Android 10, where an app can freely access its own files and the files it created in shared storage, but needs explicit permission or a system picker to touch anything else.

Before scoped storage, any app with broad storage permission could read and write essentially the entire external storage, other apps' files included, which was a privacy problem the platform had to fix.

Under scoped storage, access splits into three lanes.

  • App-specific storage, context.filesDir and context.getExternalFilesDir(). Fully private to your app, no permission needed, and it's wiped when the app is uninstalled.
  • Shared media, images, video, and audio your app creates. Accessed through MediaStore, and you get automatic access to files you created without asking, but reading another app's media needs its own grant.
  • Shared documents and other files. Reached through the Storage Access Framework, the user picks the file or folder through a system UI, and your app gets a URI it can persist access to, rather than a raw filesystem path.

The practical effect for an interview answer is that direct File paths into shared storage mostly stop working, you have to go through MediaStore for media or the Storage Access Framework's document picker for everything else, and you no longer need the broad READ_EXTERNAL_STORAGE permission for files your own app created.

Read more Data and file storage overview (opens in a new tab)

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

SharedPreferences & DataStore

How would you implement observable SharedPreferences or an observable database (observing a key/table/query)?

Tier: Less commonDifficulty: Medium

For SharedPreferences you wrap OnSharedPreferenceChangeListener in a Flow, for a database you either lean on Room's built-in Flow support or roll your own notification around the table.

For SharedPreferences, the platform already gives you a listener, you just adapt it to something coroutine-friendly.

fun SharedPreferences.observeKey(key: String): Flow<Unit> = callbackFlow {
    val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
        if (changedKey == key) trySend(Unit)
    }
    registerOnSharedPreferenceChangeListener(listener)
    awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
}

callbackFlow gives you a Flow backed by a callback API, and awaitClose is what unregisters the listener when the collector goes away, so you don't leak it.

For a database, if you're on Room, you don't need to build this yourself, any DAO query can return Flow<T> directly and Room re-runs the query and emits a new value whenever a table it touched changes, using its own InvalidationTracker under the hood.

@Query("SELECT * FROM User WHERE id = :id")
fun observeUser(id: String): Flow<User>

If you're not on Room and working with raw SQLite, you build the same idea manually, a ContentObserver registered on a ContentProvider URI, or your own in-process pub-sub that you call after every write to the table, which any listener can collect from. The pattern in both cases is the same, don't poll the source, register a callback that fires on change and expose it as a cold Flow so callers only pay for it while they're actually collecting.

SQLite & Room

How do you optimize queries in SQLite for better performance?

Tier: Less commonDifficulty: Medium

The biggest wins come from indexing the columns you actually filter and sort on, and from batching writes into transactions, most SQLite slowness on Android traces back to one of those two.

  • Add indexes on columns used in WHERE, JOIN, and ORDER BY. Without one, SQLite scans every row to find a match. CREATE INDEX idx_user_email ON User(email) turns a linear scan into a lookup. Don't over-index though, every index also slows down inserts and updates since it has to be maintained too.
  • Wrap multiple writes in a single transaction. Each individual insert or update outside a transaction is its own disk sync by default, which is slow. Batching a hundred inserts into one transaction can be an order of magnitude faster than running them one at a time.
  • Select only the columns you need. SELECT * pulls every column off disk even if you use two of them. Naming the columns you actually need cuts down I/O, especially on wide tables.
  • Use EXPLAIN QUERY PLAN to check whether a query is actually using your index. It's easy to add an index and have a query silently ignore it because of a type mismatch or a function wrapped around the column, this is how you catch that.
  • Turn on Write-Ahead Logging. WAL lets readers and a writer operate concurrently instead of blocking each other, which matters a lot on Android where a background sync write shouldn't stall a UI read.
  • Paginate large result sets with LIMIT and OFFSET instead of loading everything and filtering in Kotlin, or better, back a RecyclerView with Room's PagingSource so you never materialize more rows than the screen can show.

In Room specifically, most of this still applies since Room queries compile down to the same SQLite engine, an index or a badly shaped query is exactly as slow whether you wrote the SQL by hand or Room generated it for you.

Storage & Serialization

Compare FlatBuffers vs JSON.

Tier: Less commonDifficulty: Medium

FlatBuffers is a binary serialization format you can read directly without parsing or allocating objects first, JSON is human-readable text you have to parse into objects before you can use it.

  • Parsing cost. JSON has to be tokenized and turned into an object graph before your code touches a single field, that's real CPU time and garbage on every read. FlatBuffers stores data in a fixed binary layout, so reading a field is just an offset lookup into the raw buffer, no parse step and no intermediate objects.
  • Memory. Because there's no parse step, FlatBuffers never builds a full in-memory copy of the data, it reads straight out of the buffer it was given, even a memory-mapped file. JSON parsing allocates a full object tree, which costs more memory and triggers more garbage collection, especially painful for large payloads on a phone.
  • Size on the wire. JSON is text, so numbers, punctuation, and repeated field names all cost bytes. FlatBuffers is binary and schema-driven, field names aren't repeated per object, so it's generally the smaller payload for the same data.
  • Readability and tooling. JSON wins here, it's human-readable, debuggable in any browser or terminal, and every backend speaks it natively. FlatBuffers needs a schema file and generated code on both ends, and you can't just eyeball a payload in a network inspector.
  • Schema evolution. Both support adding fields without breaking old clients, but FlatBuffers makes you define the schema up front in a .fbs file, which adds a build step JSON doesn't need.

The practical answer is JSON is the default for a REST API because of how universal and debuggable it is. FlatBuffers earns its complexity when you're deserializing large or frequent payloads on a resource-constrained device and the parsing and allocation cost of JSON is measurably showing up in your profiler, think a game loading a big asset manifest, not a typical CRUD screen.