DataStore & Preferences Explained
DATA › Storage
Almost every Android app needs to persist small bits of state: a theme flag, an onboarding-seen bit, a feature toggle. Interviewers want to hear why DataStore replaced SharedPreferences as the recommended store, not just that it did, so start with what the old API actually does.
SharedPreferences loads its backing XML file into memory the first time you touch it, and every write updates that in-memory map immediately, so a read right after a write always sees the new value. Where it differs is what happens on disk. commit() performs the disk write synchronously, inline on whichever thread called it, and only returns once that write finishes, handing back a Boolean success flag. apply() updates the same in-memory map immediately but schedules the disk write on a background thread and returns nothing. Call commit() from the main thread and you can visibly stall the UI while the file writes; apply() avoids that stall because the write happens elsewhere. This is the classic SharedPreferences interview trap, and the part people get backwards is thinking apply() delays the in-memory update too. It doesn't, only the disk write is deferred.
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
// Synchronous: blocks the calling thread, returns Boolean
val ok: Boolean = prefs.edit().putString("key", "value").commit()
// Asynchronous: in-memory update is instant, disk write happens later
prefs.edit().putString("key", "value").apply()
DataStore is Google's coroutine-native replacement for SharedPreferences, and it fixes the exact gaps the old API had. Instead of a synchronous getter, reads come back as a Flow, so your UI collects a stream of values and updates reactively every time the data changes, without ever blocking a thread waiting for disk I/O. Instead of an editor object with commit or apply, writes are suspend functions that run as a single atomic transaction: the whole write either applies completely or not at all, and there's no synchronous-versus-asynchronous choice to get wrong because there's only one way to write.
The other big fix is error handling. SharedPreferences swallows IO problems, a bad file just reads back empty with no signal that anything went wrong. DataStore surfaces failures as real exceptions that propagate through the Flow or the suspend function, so you can catch them and decide how to recover instead of silently serving stale or empty data.
Setting up Preferences DataStore starts with a single declaration. You create it once using the preferencesDataStore delegate, conventionally as a top-level extension property on Context in its own file, and that delegate caches the instance so every call site gets the same DataStore rather than a fresh one. Building your own instance manually inside a function or an Activity's onCreate is the wrong pattern here, you'd risk creating more than one DataStore for the same file, which DataStore explicitly forbids.
Keys are typed, not stringly-typed like a raw map lookup. You get them from factory functions such as intPreferencesKey, stringPreferencesKey, and booleanPreferencesKey, one per primitive type. The old generic preferencesKey<T>() function was removed specifically because it let you request a key of any type without the library being able to check it made sense; the type-specific factories close that hole.
// Declared once, at file top level, cached as a singleton
private val Context.dataStore by preferencesDataStore(name = "settings")
val COUNT_KEY = intPreferencesKey("count")
val NAME_KEY = stringPreferencesKey("name")
Writing to Preferences DataStore goes through a single suspend function called edit, and what it guarantees is the important interview point: the block you pass runs as one atomic transaction. If you read the current value and derive a new one from it inside that block, the read and the write happen together, with no other writer able to interleave and no risk of losing an update to a race. Either the whole block's changes land, or none of them do, there's no partially-applied state to worry about.
This is a meaningful upgrade over SharedPreferences, where nothing stopped two editors from racing against each other on the same key. With DataStore, read-modify-write code like incrementing a counter is safe by construction, because the transaction boundary is the edit block itself, not something you have to build yourself with locks.
suspend fun incrementCounter(dataStore: DataStore<Preferences>) {
dataStore.edit { prefs ->
val current = prefs[COUNT_KEY] ?: 0
prefs[COUNT_KEY] = current + 1
}
}
Reads come from dataStore.data, which is a Flow of Preferences, and you map that down to just the field you care about. Because this Flow is backed by a real file on disk, it can fail: if the file can't be read, an IOException surfaces through the Flow instead of quietly handing you empty or stale data the way SharedPreferences effectively would.
The idiomatic way to handle that is Flow's catch operator, placed upstream of your map. Inside it you check whether the exception is an IOException, and if so you emit a safe fallback, typically emptyPreferences(), so the rest of your pipeline still gets a value to work with. Anything that isn't an IOException, you rethrow, because swallowing an unexpected crash is worse than letting it surface. This pattern is what makes DataStore's error handling structured instead of silent: you decide exactly what recovery looks like.
val countFlow: Flow<Int> = context.dataStore.data
.catch { e ->
if (e is IOException) emit(emptyPreferences())
else throw e
}
.map { prefs -> prefs[COUNT_KEY] ?: 0 }
DataStore enforces something SharedPreferences never had to: exactly one active instance per file, per process. Every read and write for a given file funnels through that single in-memory coordinator, which is precisely what lets DataStore guarantee atomicity and read-after-write consistency, a read immediately after a write always sees that write's result, because there's only one instance managing the truth.
Break that rule, say by calling DataStoreFactory.create() twice for the same file path instead of using a cached delegate, and DataStore doesn't quietly let both instances coexist. It throws an IllegalStateException the moment the second instance touches the file, because two independent instances would each think they own the source of truth, and that would break every consistency guarantee DataStore is built to provide.
// BAD: two instances for the same file
val ds1 = DataStoreFactory.create(serializer) { context.dataStoreFile("user.pb") }
val ds2 = DataStoreFactory.create(serializer) { context.dataStoreFile("user.pb") } // throws!
// GOOD: one cached instance via a top-level delegate
private val Context.userStore by dataStore(fileName = "user.pb", serializer = UserPrefsSerializer)
A DataStore file can become corrupted, disk errors and bad shutdowns happen, and when that happens a read throws a CorruptionException through the data Flow instead of returning garbage. Left unhandled, that's a crash on every single read of that file, which is worse than SharedPreferences ever was, since a broken SharedPreferences file usually just read back empty.
The recovery mechanism is a corruptionHandler you supply when you build the DataStore. A ReplaceFileCorruptionHandler takes a lambda that produces a default value, and when a CorruptionException is caught, DataStore replaces the bad file with that default instead of continuing to throw. Your app degrades to default settings rather than crashing outright, which is the behavior you want for something like a theme preference where losing the value is annoying but not catastrophic.
val Context.dataStore by preferencesDataStore(
name = "settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }
)
Migrating off SharedPreferences doesn't require writing the copy logic yourself. You supply a SharedPreferencesMigration in the migrations list when you build the DataStore, naming the legacy SharedPreferences file you want pulled in. The first time the app accesses the new DataStore after that, the migration runs automatically: every value from the old file gets copied across, and by default the original SharedPreferences file is deleted afterward, so the app can't end up reading from two divergent sources of truth.
This runs once per install, transparently, before any of your own reads or writes touch the DataStore, so by the time your code executes, the data is already in its new home and behaves exactly like any other DataStore value.
val Context.dataStore by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "legacy_prefs"))
}
)
Preferences DataStore is still untyped underneath, a map of keys to values with no compile-time schema, so a typo in a key name or a wrong type parameter only fails at runtime. Proto DataStore is the typed alternative: you define your data's shape in a .proto file, and the generated class becomes the single, compile-time-checked source of truth for both storage and your API. Reach for it when you want the compiler catching field typos and type mismatches instead of a runtime crash; reach for Preferences DataStore when a loose bag of keys is genuinely simpler for what you're storing.
To make Proto DataStore work, you supply a Serializer<T> that tells it how to turn your object into bytes and back. It needs a defaultValue for when no file exists yet, a suspend readFrom that parses an InputStream into your type, and a suspend writeTo that writes your type out to an OutputStream. This Serializer is what DataStore calls internally on every read and write.
object UserPrefsSerializer : Serializer<UserPrefs> {
override val defaultValue: UserPrefs = UserPrefs.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPrefs =
UserPrefs.parseFrom(input)
override suspend fun writeTo(t: UserPrefs, output: OutputStream) =
t.writeTo(output)
}
Writing to Proto DataStore doesn't use edit, because there's no untyped map to hand you a mutable view of. Instead you call the suspend function updateData, passing a lambda that receives the current typed object and returns the new one. Protocol buffer message objects are immutable, so the pattern is always the same: take the current value, call toBuilder(), set the fields you want to change, call build(), and return that as the updated value. Like edit on Preferences DataStore, this runs as a single atomic transaction, the read of the current value and the write of the new one are inseparable.
Mixing this up is a common slip under interview pressure: reaching for edit { } out of habit when the type in front of you is a Proto DataStore. If you see updateData, you're looking at Proto DataStore; if you see edit, you're looking at Preferences DataStore.
suspend fun setTheme(dataStore: DataStore<UserPrefs>, theme: String) {
dataStore.updateData { current ->
current.toBuilder()
.setTheme(theme)
.build()
}
}
One current-events fact interviewers like to probe, precisely because it's easy to be behind on: EncryptedSharedPreferences, from androidx.security:security-crypto, is deprecated. For years it was the standard answer for storing something sensitive like an auth token locally, encrypting the SharedPreferences file transparently under the hood.
The current guidance moved away from it. Where possible, avoid persisting real secrets client-side at all, prefer re-fetching a token from the server over keeping a long-lived one on disk. Where you must store something locally, lean on platform disk encryption plus the Android Keystore directly, or an actively maintained crypto library, rather than a library that Google itself no longer recommends for new code. If you name EncryptedSharedPreferences as your answer for secure local storage in an interview today, be ready to immediately follow it with the fact that it's deprecated and what you'd use instead.
DataStore is deliberately scoped to small, simple data: a handful of settings, a few feature flags, a single typed object. Every write to Preferences DataStore rewrites the whole file, not just the changed key, which is fine for a small settings blob and a poor fit for anything that grows large or needs partial updates. It also has no query capability at all, you read the whole thing back and filter in code, there's no equivalent of a WHERE clause.
For a large, complex, or genuinely relational dataset, the answer isn't Proto DataStore either, it's Room. Room gives you SQL queries, indexes, joins, and partial updates against a real database file, none of which DataStore is designed to provide. Knowing where that line sits, small key-value or single-object settings go in DataStore, structured or queryable data goes in Room, is exactly the kind of judgment interviewers are checking for when they ask whether you'd use DataStore for a given feature.