Almost every Android codebase I inherit, whether at a full-time job or on a freelance contract, has a SharedPreferences file quietly holding auth tokens, onboarding flags and theme settings. It works until it doesn't: an ANR traced to commit() on the main thread, a race between two apply() calls, or a crash because someone stored an Int and read a Long. Jetpack DataStore fixes most of that, and migrating to it is one of the cheapest reliability wins you can ship. In this post I'll walk through how I migrate real apps from SharedPreferences to Jetpack DataStore in Kotlin without losing a single user's data.

This isn't a theoretical overview. It's the playbook I've refined over 8+ years of Android work, including my time as a Senior Android Engineer at CodeBrew Labs and the client apps I've touched since.

Why I Replace SharedPreferences With Jetpack DataStore

SharedPreferences was designed before coroutines existed, and it shows. The API looks synchronous, but under the hood it does disk I/O that can block whatever thread calls it. Here's what actually bites teams in production:

  • Main-thread disk I/O. The first read loads the whole XML file. Calling getString() during startup can block the UI thread, and apply() still forces pending writes to flush during certain lifecycle events, which is a classic source of ANRs.
  • No error signaling. If a write fails, you'll never know. apply() returns nothing.
  • No type safety. Keys are strings, types are your problem. A ClassCastException from a mismatched key is easy to introduce in a refactor.
  • No observability. OnSharedPreferenceChangeListener is clunky and holds weak references, so it silently stops firing if you don't keep a strong reference.

Jetpack DataStore solves these with a coroutine-first design. Reads are exposed as a Flow, writes are suspending and transactional, and I/O errors surface as exceptions you can actually handle. Updates are atomic: an edit block either fully succeeds or doesn't apply at all.

When not to use DataStore

DataStore is for small, simple data: settings, flags, tokens, a user ID. If you need queries, relationships, partial updates or large lists, use Room. DataStore rewrites the whole file on every change, so it scales poorly for big datasets.

Preferences DataStore vs Proto DataStore

Jetpack DataStore comes in two flavors, and picking the right one up front saves a second migration later.

Preferences DataStore

A typed key-value store. You define keys like booleanPreferencesKey("dark_mode") and read or write values against them. No schema, no code generation. It's the natural target when migrating from SharedPreferences because the mental model is identical, and there's a built-in migration helper.

Proto DataStore

Stores a single typed object. Traditionally that means a Protocol Buffers schema, but you can implement the Serializer interface yourself, and many teams use kotlinx.serialization with JSON instead. You get full type safety and default values defined in one place.

My rule of thumb in client work:

  • Migrating an existing app with scattered flags? Start with Preferences DataStore. It maps one-to-one with what you have.
  • New feature with a structured settings object (notification preferences, filter state, a cached user profile)? Use a typed DataStore with a serializer.

Here's a minimal typed DataStore using kotlinx.serialization, which I reach for when I don't want the protobuf toolchain:

@Serializable
data class NotificationSettings(
    val pushEnabled: Boolean = true,
    val quietHoursStart: Int? = null,
    val quietHoursEnd: Int? = null
)

object NotificationSettingsSerializer : Serializer<NotificationSettings> {
    override val defaultValue = NotificationSettings()

    override suspend fun readFrom(input: InputStream): NotificationSettings =
        try {
            Json.decodeFromString(input.readBytes().decodeToString())
        } catch (e: SerializationException) {
            throw CorruptionException("Unable to read NotificationSettings", e)
        }

    override suspend fun writeTo(t: NotificationSettings, output: OutputStream) {
        output.write(Json.encodeToString(t).encodeToByteArray())
    }
}

val Context.notificationSettingsStore: DataStore<NotificationSettings> by dataStore(
    fileName = "notification_settings.json",
    serializer = NotificationSettingsSerializer
)

Throwing CorruptionException matters. It lets a corruption handler recover instead of crashing the app on every launch.

Migrating SharedPreferences to Jetpack DataStore Safely

The good news: Jetpack DataStore ships with SharedPreferencesMigration. It runs once, before the first read completes, copies your existing keys into DataStore, and by default deletes the old SharedPreferences file afterward. Key names and value types carry over, so "dark_mode" stored as a Boolean becomes readable via booleanPreferencesKey("dark_mode").

private const val LEGACY_PREFS_NAME = "user_prefs"

val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings",
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
    produceMigrations = { context ->
        listOf(SharedPreferencesMigration(context, LEGACY_PREFS_NAME))
    }
)

A few things I do on every migration:

  1. Inventory every key first. Grep for getSharedPreferences, PreferenceManager.getDefaultSharedPreferences and any wrapper classes. Apps often have three or four preference files, not one. Each file needs its own migration entry.
  2. Migrate only what you need. SharedPreferencesMigration accepts a keysToMigrate set. This is a good moment to drop dead keys rather than carry them forever.
  3. Switch all reads and writes in the same release. Once migration runs, the old file is gone. If some screen still reads SharedPreferences directly, it'll see empty values. Half-migrated code is the number one cause of "users got logged out after the update" bugs I've debugged.
  4. Test the upgrade path, not just fresh installs. Install the old build, populate data, then install the new build over it. A fresh install never exercises the migration.

Watch the default preferences file

If you used PreferenceManager.getDefaultSharedPreferences(), the file name is your package name followed by _preferences. Pass that exact name to the migration, or nothing gets copied and nobody notices until support tickets arrive.

Wrapping DataStore in a Repository and Hilt Module

I never let ViewModels touch DataStore keys directly. A repository exposes a domain model as a Flow and offers intention-revealing write functions. That keeps keys in one file and makes testing trivial.

data class UserSettings(
    val darkMode: Boolean,
    val authToken: String?,
    val onboardingDone: Boolean
)

class SettingsRepository @Inject constructor(
    private val dataStore: DataStore<Preferences>
) {
    private object Keys {
        val DARK_MODE = booleanPreferencesKey("dark_mode")
        val AUTH_TOKEN = stringPreferencesKey("auth_token")
        val
    }

    val settings: Flow<UserSettings> = dataStore.data
        .catch { e ->
            if (e is IOException) emit(emptyPreferences()) else throw e
        }
        .map { prefs ->
            UserSettings(
                darkMode = prefs[Keys.DARK_MODE] ?: false,
                authToken = prefs[Keys.AUTH_TOKEN],
                ?: false
            )
        }
        .distinctUntilChanged()

    suspend fun setDarkMode(enabled: Boolean) {
        dataStore.edit { it[Keys.DARK_MODE] = enabled }
    }

    suspend fun completeOnboarding() {
        dataStore.edit { it[Keys.ONBOARDING_DONE] = true }
    }

    suspend fun clearSession() {
        dataStore.edit { it.remove(Keys.AUTH_TOKEN) }
    }
}

For dependency injection, I prefer PreferenceDataStoreFactory over the property delegate in larger apps because it makes the scope and file explicit:

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {

    @Provides
    @Singleton
    fun provideSettingsDataStore(
        @ApplicationContext context: Context
    ): DataStore<Preferences> = PreferenceDataStoreFactory.create(
        corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
        migrations = listOf(SharedPreferencesMigration(context, "user_prefs")),
        scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
        produceFile = { context.preferencesDataStoreFile("settings") }
    )
}

In the ViewModel, collect settings with stateIn and expose a StateFlow to Compose. Because DataStore emits only after the first disk read, I model an initial loading state rather than defaulting to false, which prevents the classic flash of the onboarding screen for users who already finished it.

Pitfalls I Hit and How I Test

Multiple instances for the same file

Creating two DataStore instances pointing at the same file throws an IllegalStateException at runtime. This usually happens when someone calls PreferenceDataStoreFactory.create() inside a function instead of a singleton. Always provide it once, at the application level.

Blocking reads with runBlocking

Teams migrating synchronous code are tempted to write runBlocking { dataStore.data.first() } everywhere. That reintroduces the exact main-thread I/O you were trying to remove. If you truly need a value synchronously at startup, such as a base URL, load it once asynchronously and cache it in memory, or restructure the flow so the UI waits on a loading state.

DataStore is not encrypted

DataStore files are plain on disk, just like SharedPreferences. For auth tokens, I encrypt values with a key held in the Android Keystore before writing, or keep sensitive data out of local storage altogether. Don't assume migrating to DataStore improved your security posture; it didn't.

Multi-process apps

The standard DataStore isn't safe across processes. If you have a separate process for a service or widget, use MultiProcessDataStoreFactory instead.

Testing the repository

DataStore is easy to test with a real file in a temporary folder. No mocking required, which means your tests exercise actual serialization behavior:

class SettingsRepositoryTest {
    @get:Rule val tmpFolder = TemporaryFolder()

    @Test
    fun darkMode_persistsAcrossReads() = runTest {
        val dataStore = PreferenceDataStoreFactory.create(
            scope = backgroundScope,
            produceFile = { tmpFolder.newFile("test.preferences_pb") }
        )
        val repo = SettingsRepository(dataStore)

        repo.setDarkMode(true)

        assertTrue(repo.settings.first().darkMode)
    }
}

For the migration itself, I write an instrumented test that populates a real SharedPreferences file, builds the DataStore with the migration, reads the first emission and asserts each key arrived with the right type. It's ten minutes of work and it has caught key-name typos more than once in my experience.

The migration is the only part of this change that can permanently lose user data. Test it like it matters, because it does.

On freelance projects, this kind of change is also an easy one to explain to non-technical clients: fewer ANRs, fewer random logouts, safer upgrades. Small, low-risk reliability improvements like this build trust, and in my Upwork work, trust is what turns one contract into ongoing work.

Key Takeaways

  • Jetpack DataStore replaces SharedPreferences with async, transactional, error-aware storage exposed as a Flow; use Room for anything large or relational.
  • Start migrations with Preferences DataStore and SharedPreferencesMigration, and move every read and write in the same release.
  • Wrap DataStore in a repository, provide a single instance through DI, and model an initial loading state in the UI.
  • Never use runBlocking reads on the main thread, and remember DataStore does not encrypt anything for you.
  • Test the upgrade path from the old build, not just fresh installs, using real temporary files instead of mocks.