Jetpack DataStore in 2026: migrating off SharedPreferences without losing a setting
A practical guide to moving from SharedPreferences to Jetpack DataStore on Android — the async pitfalls, the migration path that preserves existing values, and how to test it.
SharedPreferences still works. It also still does a synchronous disk read on first access, has no compile-time type safety, and offers no way to observe a value changing without wiring up a listener by hand. Jetpack DataStore fixes all three, and in 2026 it’s the default Google recommends for anything beyond a single throwaway flag. The part that stalls most migrations isn’t learning the new API — it’s moving existing users over without resetting their settings on the next update.
What’s actually wrong with SharedPreferences
getSharedPreferences().getString(...) looks synchronous and cheap, but on first access in a process it can block the calling thread on a real file read — often the main thread, during app startup. There’s no built-in way to collect changes as a stream; you register a OnSharedPreferenceChangeListener and manage its lifecycle yourself. And every read is stringly-typed: a typo in a key name compiles fine and fails silently at runtime by returning the default value.
None of these are exotic edge cases. They’re the normal way SharedPreferences behaves, and Preferences DataStore was built specifically to replace them without changing the mental model too much.
Preferences DataStore: the same shape, done safely
A Preferences DataStore instance is built once, typically as an extension property on Context:
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings"
)
Reads come back as a Flow, so you get change notifications for free instead of building them yourself:
val TIMER_MINUTES = intPreferencesKey("timer_minutes")
val timerMinutes: Flow<Int> = context.settingsDataStore.data
.map { prefs -> prefs[TIMER_MINUTES] ?: 25 }
Writes go through a suspend function, so they’re never accidentally called from the main thread:
suspend fun setTimerMinutes(context: Context, minutes: Int) {
context.settingsDataStore.edit { prefs ->
prefs[TIMER_MINUTES] = minutes
}
}
This is close enough to SharedPreferences that the rewrite itself is mechanical. The risk is entirely in what happens to the data that’s already on a user’s device.
The part people skip: migrating existing values
DataStore ships a SharedPreferencesMigration for exactly this, and it’s easy to miss because nothing forces you to use it — your app will build and run fine without it, then quietly reset every setting to its default on the update where you swap the storage backend.
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "settings_prefs"))
}
)
The migration runs once, the first time the DataStore is opened after this code ships: it reads the old SharedPreferences file, copies every entry into the new Preferences store, and leaves the old file in place (it does not delete it — that’s your call to make separately, once you’re confident the migration has run everywhere). If a key’s type doesn’t map cleanly — a Set<String> where you now want a List, say — you filter it out in the migration’s shouldRunMigration or transform it explicitly rather than letting a type mismatch throw at read time.
Get the old preferences file’s name wrong — a common mistake if it was created with a custom name rather than the package-default one — and the migration finds nothing to copy, and every user resets to defaults on update. Before shipping, check the exact name with context.getSharedPreferences("name", MODE_PRIVATE) calls already in the codebase rather than guessing it.
When Preferences DataStore isn’t enough
Preferences DataStore keeps a flat, stringly-typed key space — it solved the threading and observability problems but not the type-safety one. Proto DataStore replaces the key-value bag with a schema you define once in a .proto file, so a settings object either matches the schema or fails to compile — there’s no null result from a typo’d key at runtime. It’s worth the extra setup once a settings screen grows past a handful of flags, or once nested objects (a notification preference with its own sound, vibration, and quiet-hours fields) start showing up in the key space as three or four separately-namespaced keys instead of one structured value. In Mintly, the timer’s sound, vibration, and auto-restart settings moved to a small Proto DataStore message for exactly this reason — once related settings need to be read and written together, a flat key-value store starts fighting you.
Testing the migration, not just the new API
The new read/write code is straightforward enough to skip testing. The migration is not — it’s the one piece of this change that runs exactly once, silently, against real user data, with no chance to retry if it’s wrong. A minimal test seeds an actual SharedPreferences file, opens a DataStore with the migration attached, and asserts the values survived:
@Test
fun migration_preservesExistingTimerSetting() = runTest {
val prefs = context.getSharedPreferences("settings_prefs", Context.MODE_PRIVATE)
prefs.edit().putInt("timer_minutes", 45).commit()
val dataStore = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "settings_prefs")),
produceFile = { File(context.filesDir, "test_settings.preferences_pb") }
)
val minutes = dataStore.data.first()[intPreferencesKey("timer_minutes")]
assertEquals(45, minutes)
}
Run this once against every key currently in production, not just the ones on a new feature branch — the migration has to carry forward everything a real device has accumulated, including settings from features that shipped years before this rewrite.
The checklist
Before merging a SharedPreferences-to-DataStore migration: the old preferences file name is confirmed against the actual getSharedPreferences() call in the codebase, SharedPreferencesMigration is wired in for every key currently in use, a test seeds the old format and asserts each value survives, and the old file is left untouched until telemetry confirms the migration has run on the installed base. It’s a small amount of extra care for a change users should never notice at all — which, for a settings migration, is exactly the point.
// Related reading
More from the journal
Baseline Profiles on Android: what actually moves your cold-start time in 2026
A practical guide to Android Baseline Profiles — generating one with Macrobenchmark, wiring it into Gradle, measuring the real gain, and the three other things that move cold start more.
Building Mintly: keeping a focus timer accurate when Android kills the process
A running Pomodoro timer has a harder reliability problem than a one-shot reminder. Here's how Mintly survives Doze, process death, and screen-off drift with a foreground service and a wall-clock end time.
ML Kit barcode scanning on Android in 2026: how Stocky adds a pantry item in under a second
A practical 2026 guide to on-device barcode scanning with ML Kit and CameraX: format tuning, offline product lookup, and the partial-use math behind a pantry app.