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. The old answer was SharedPreferences; the modern answer is **DataStore**, and interviewers want to hear you explain why the switch matters, not just that it happened.

SharedPreferences reads happen off an XML file loaded into memory on first access, and while writes are cached in memory instantly, its APIs give you no reliable async signal for when a write actually lands, and it doesn't surface IO errors in a structured way. DataStore rebuilds the same job around Kotlin coroutines: reads come back as a Flow, writes are suspend functions that run as a single atomic transaction, and if something goes wrong (a corrupt file, a disk error) that failure propagates as a real exception you can handle instead of vanishing silently.

Before DataStore existed, this was the classic SharedPreferences interview trap: commit() versus apply().

val prefs = getSharedPreferences("prefs", MODE_PRIVATE)

// commit(): synchronous, blocks the caller, returns Boolean success
val ok: Boolean = prefs.edit().putString("key", "value").commit()

// apply(): updates the in-memory map immediately, writes to disk async, returns Unit
prefs.edit().putString("key", "value").apply()

Both update the in-memory cache immediately, so a read right after either call sees the new value. The difference is only about the *disk* write: commit() performs it inline on the calling thread and hands you back a Boolean, so calling it from the main thread can visibly stall the UI. apply() schedules the disk write on a background thread and returns Unit, no confirmation, but no jank either. Default to apply() unless you specifically need the synchronous success or failure result.

Preferences DataStore is the direct, untyped replacement for SharedPreferences, same key-value shape, coroutine-based API. You declare a single instance per file with the preferencesDataStore delegate, typically at the top level of a file so it's created exactly once:

private val Context.dataStore by preferencesDataStore(name = "settings")

val COUNT_KEY = intPreferencesKey("count")

Keys come from typed factory functions, intPreferencesKey, stringPreferencesKey, booleanPreferencesKey, and so on, there's no generic preferencesKey<T>() anymore. Writes go through the suspend edit {} block, which runs as one atomic transaction, read-modify-write included:

suspend fun increment() {
    context.dataStore.edit { prefs ->
        val current = prefs[COUNT_KEY] ?: 0
        prefs[COUNT_KEY] = current + 1
    }
}

Reads come from dataStore.data, a Flow<Preferences> you map down to the value you want:

val countFlow: Flow<Int> = context.dataStore.data
    .map { prefs -> prefs[COUNT_KEY] ?: 0 }

Because this is a Flow reading from disk, an IOException can surface if the file can't be read. The idiomatic fix is the catch operator upstream of your map, recovering with a safe default while letting anything unexpected still propagate:

context.dataStore.data
    .catch { e ->
        if (e is IOException) emit(emptyPreferences()) else throw e
    }
    .map { prefs -> prefs[COUNT_KEY] ?: 0 }

This is exactly the behavior SharedPreferences never gave you: a real, catchable signal that something went wrong, instead of a silent empty read.

Preferences DataStore is untyped, essentially a map of keys to values with no compile-time schema. **Proto DataStore** is the typed alternative: you define your data shape in a .proto file, and the generated class becomes the single source of truth for both storage and the API:

object UserPrefsSerializer : Serializer<UserPrefs> {
    override val defaultValue: UserPrefs = UserPrefs.getDefaultInstance()
    override suspend fun readFrom(input: InputStream) = UserPrefs.parseFrom(input)
    override suspend fun writeTo(t: UserPrefs, output: OutputStream) = t.writeTo(output)
}

private val Context.userPrefsStore by dataStore(
    fileName = "user_prefs.pb",
    serializer = UserPrefsSerializer
)

Writes use updateData {} instead of edit {}, since there's no untyped map to hand you, just the current typed object to transform:

suspend fun setTheme(theme: String) {
    context.userPrefsStore.updateData { current ->
        current.toBuilder().setTheme(theme).build()
    }
}

Reach for Proto DataStore when you want compile-time-checked fields; reach for Preferences DataStore when a loose set of keys is genuinely simpler.

If you're migrating an existing app off SharedPreferences, you don't have to write the copy logic by hand. Pass a SharedPreferencesMigration in the DataStore's migrations list:

val Context.dataStore by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(SharedPreferencesMigration(context, "legacy_prefs"))
    }
)

On first access, DataStore runs the migration, copying every value out of the "legacy_prefs" SharedPreferences file and into itself, then by default deletes the original file so the app can't drift between the two stores. This runs once per install, transparently, before any of your own reads or writes touch the new DataStore.

Two failure modes are worth knowing cold. First, a corrupted DataStore file surfaces as a CorruptionException through the data Flow; you recover from it by supplying a ReplaceFileCorruptionHandler when you build the store, which swaps in a default value instead of leaving your app permanently broken:

val Context.dataStore by preferencesDataStore(
    name = "settings",
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }
)

Second, DataStore enforces **exactly one active instance per file per process**. That's what guarantees atomicity and read-after-write consistency, every write and read for a file funnels through the same in-memory coordinator. Create a second DataStore pointed at the same file, say by calling DataStoreFactory.create() twice instead of using a cached delegate, and it throws too: an IllegalStateException, not a silent second copy.

One more current-events fact interviewers may probe: EncryptedSharedPreferences (from androidx.security:security-crypto) is **deprecated**. It used to be the go-to for storing secrets like auth tokens, but Google now steers away from it, the guidance is to avoid persisting sensitive secrets client-side where possible, lean on platform disk encryption and the Android Keystore, and use actively maintained crypto libraries for anything that truly must be stored.

Pulling the whole topic together: DataStore replaces SharedPreferences with a coroutine-native API, Flow reads, suspend writes, that never blocks and never swallows errors. Preferences DataStore is the untyped drop-in; Proto DataStore adds a real compile-time schema via a Serializer<T> and updateData {}. SharedPreferencesMigration handles the transition from the old API, ReplaceFileCorruptionHandler handles a corrupted file, and the one-instance-per-file rule is what makes all of DataStore's atomicity guarantees possible in the first place.

Back to DataStore & Preferences