Migrating Android SharedPreferences to DataStore Without Losing User Settings
A practical migration path from SharedPreferences to Jetpack DataStore that preserves existing settings, avoids dual-write traps, and keeps reads reactive.
Table of Contents14 sections

SharedPreferences migrations are deceptively easy to start and surprisingly easy to get wrong.
The safe pattern is to make DataStore the single owner of the setting, migrate the old preference once when DataStore is created, expose reads as Flow, and stop writing to the legacy store after the cutover. The migration should preserve existing values while letting new installations start directly on DataStore.
This guide focuses on Preferences DataStore because it maps naturally to key-value settings. If the data is relational, large, or needs partial updates and referential integrity, use Room instead. Android’s DataStore guidance makes the same distinction.
Why migrate instead of wrapping SharedPreferences forever?
SharedPreferences is familiar, but modern Android architecture benefits from asynchronous storage and observable state. DataStore is designed around Kotlin coroutines and Flow, and its updates are transactional.
That changes how settings fit into an application. Instead of asking storage for a value whenever a screen needs it, a repository can expose a stream and let the UI react to changes.
UI
|
ViewModel
|
SettingsRepository
|
Preferences DataStore
The important part is not replacing one API call with another. It is moving ownership of persisted settings behind one boundary. The same principle appears in When to Modularize an Android App Without Overengineering: boundaries should solve a real ownership problem rather than add ceremony.
Inventory the old keys before touching code
Start by listing every preference key, its type, its default value, and every place that writes it.
| Legacy key | Type | Default | New owner |
|---|---|---|---|
dark_mode |
Boolean | false |
SettingsRepository |
sync_wifi_only |
Boolean | true |
SettingsRepository |
page_size |
Int | 20 |
SettingsRepository |
This catches two migration bugs early. The same string key may be read with inconsistent defaults in different parts of an old app, and some preferences may no longer be settings at all. A cached API response, authentication secret, or relational record should not automatically move into DataStore just because it once lived in SharedPreferences.
Treat migration as a chance to clarify ownership.
Create one DataStore instance
For Preferences DataStore, keep one instance for a given file in the process. A common Android setup uses the preferencesDataStore delegate at top level:
val Context.settingsDataStore by preferencesDataStore(
name = "settings"
)
Then inject a repository that depends on that instance rather than letting Activities, Fragments, and composables reach into persistence directly.
class SettingsRepository(
private val dataStore: DataStore<Preferences>
) {
private object Keys {
val darkMode = booleanPreferencesKey("dark_mode")
val wifiOnly = booleanPreferencesKey("sync_wifi_only")
}
val darkMode: Flow<Boolean> =
dataStore.data.map { prefs ->
prefs[Keys.darkMode] ?: false
}
}
The repository now owns both the storage key and the application default. That prevents defaults from drifting across callers.
Migrate existing preferences at DataStore creation
DataStore supports migrations that run before the store becomes available to normal consumers. For a SharedPreferences cutover, configure a migration for the legacy preferences file when DataStore is created.
existing install
|
legacy SharedPreferences found
|
one-time migration
|
DataStore becomes readable
|
all future reads and writes use DataStore
Do not build a permanent “read DataStore, otherwise read SharedPreferences” fallback into every repository call. That creates two sources of truth and makes deletion of the old path almost impossible. The migration boundary is the fallback.
Keep keys stable unless you have a reason to rename them
If the old key already has a good name and compatible type, preserving it reduces migration logic. When a rename is necessary, map it explicitly.
If the legacy preference was named push_enabled and the new key is notifications_enabled, the migration should intentionally translate one into the other. Type changes need the same care. Turning a string such as "20" into an integer 20 needs validation and a fallback for malformed legacy values.
A migration is production parsing code. Assume old installations contain values created by every app version you have shipped.
Stop dual writes after the cutover
A tempting rollout strategy is to write both stores for several releases. That looks safe but creates ambiguity. If one write succeeds and the other fails, which value is authoritative? If an old code path still updates only SharedPreferences, should DataStore overwrite it later?
Prefer a clear ownership switch:
before migration: SharedPreferences owns the value
migration: copy or transform the legacy value
after migration: DataStore owns the value
If staged rollout is necessary, make the compatibility period explicit and observable. Do not leave dual writes as an undocumented permanent state.
Make writes transactional and suspendable
Preferences DataStore updates should happen through its edit or update API rather than by mutating an in-memory map.
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[Keys.darkMode] = enabled
}
}
A ViewModel can launch the write in its scope and continue collecting the resulting Flow.
fun setDarkMode(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.setDarkMode(enabled)
}
}
Avoid maintaining a second mutable UI copy unless the screen genuinely needs optimistic state. For normal settings, the persisted stream can remain the source of truth.
Handle read failures deliberately
Storage can fail. A robust read pipeline distinguishes recoverable I/O problems from programming errors.
val settings: Flow<UserSettings> =
dataStore.data
.catch { error ->
if (error is IOException) {
emit(emptyPreferences())
} else {
throw error
}
}
.map { prefs ->
UserSettings(
darkMode = prefs[Keys.darkMode] ?: false,
wifiOnly = prefs[Keys.wifiOnly] ?: true
)
}
Do not turn every exception into defaults. Silent recovery can make corruption look like a legitimate user choice.
Test the upgrade path, not only a fresh install
A migration that works on a clean emulator proves very little. At minimum, test a fresh install, a fully populated legacy install, partial legacy state, renamed or converted values, restart after migration, and a DataStore write followed by another restart.
The critical assertion is that migration happens once and that later DataStore values are not overwritten by stale legacy data.
For regression testing, create the old preference state first, initialize DataStore, collect the migrated value, then update DataStore and initialize the app path again. The second initialization should preserve the new DataStore value.
Decide what should not be migrated
Good migration candidates include durable user choices such as theme, notification toggles, sorting preferences, and lightweight feature configuration.
Poor candidates include expired session state, caches, derived values, large serialized payloads, and data that belongs in a relational database.
This is also a useful time to define reset behavior. If users clear application data, DataStore is local application state and will be removed with the rest of the app’s private data. The broader storage implications are covered in Understanding Clear Data on Android.
Preferences DataStore or typed DataStore?
Preferences DataStore is a good migration target when the data remains a small set of independent key-value settings.
Typed DataStore is a better fit when the settings form one structured object and schema evolution matters. Current Android documentation supports typed DataStore with custom serialization as well as Preferences DataStore. The DataStore release documentation is the best place to verify current artifacts and serialization options before changing dependencies.
Neither option replaces Room for relational data. Choose based on the shape and invariants of the data, not on which API requires fewer lines in the first commit.
A practical migration checklist
[ ] Inventory legacy keys, types, defaults, and writers
[ ] Decide which values should actually survive
[ ] Create one DataStore owner
[ ] Add the one-time SharedPreferences migration
[ ] Move reads behind repository Flows
[ ] Move writes exclusively to DataStore
[ ] Remove or quarantine legacy writers
[ ] Test partial and malformed legacy state
[ ] Test restart after migration
[ ] Verify a migrated value is not overwritten later
After release, monitor crashes and storage-related errors before deleting compatibility code. Once the migration has proven stable across the supported upgrade window, remove dead legacy access rather than carrying it indefinitely.
The migration is an ownership change
The safest way to think about this work is not “replace SharedPreferences with DataStore.” It is to identify old ownership, migrate once, establish DataStore as the only owner, expose state reactively, and delete the legacy path.
That framing prevents most of the difficult bugs: dual writes, inconsistent defaults, stale fallback reads, and migrations that rerun forever.
DataStore gives Android applications a cleaner persistence primitive for small settings. The migration succeeds when the rest of the app no longer needs to know that SharedPreferences ever existed.
Continue Exploring
You Might Also Like

When to Modularize an Android App Without Overengineering
A practical guide to deciding when Android modules help, what boundaries to extract first, and how to avoid turning modularization into architecture overhead.

Android Notification Opens vs App Opens: Measure the Entry Point
A practical Android analytics pattern for separating notification-driven sessions from ordinary app launches without double-counting engagement.

Parallelize Independent Android Refreshes with Coroutines
Learn when Android repository calls can run concurrently, how structured concurrency changes failure behavior, and how to test parallel refreshes safely.