SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:37
UP00:01
MOD · ARTICLE · DATA LAYERS/N · AX-JETPACPUBLISHED
All Articles
Data Layer · Updated 11 MIN

Jetpack DataStore: The Modern Replacement for SharedPreferences

Migrate from SharedPreferences to DataStore: type-safe Proto DataStore, reactive Preferences DataStore, and production patterns for settings and onboarding.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why move off SharedPreferences?

SharedPreferences has been in Android since API level 1, and Google's own page for it now opens with a redirect: "DataStore is a modern data storage solution that you should use instead of SharedPreferences. It builds on Kotlin coroutines and Flow, and overcomes many of the drawbacks of SharedPreferences" (Android). The drawback you can read off that same page is the write path. "apply() changes the in-memory SharedPreferences object immediately but writes the updates to disk asynchronously", so a successful return tells you nothing about the disk. The alternative is commit(), and "because commit() is synchronous, you should avoid calling it from your main thread because it could pause your UI rendering". You choose between not knowing and blocking. Jetpack DataStore removes the choice: it "uses Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally". Reads are a Flow. Writes are suspend functions that run as a transaction. An earlier version of this article backed the case with an audit percentage and a per-read jank range in milliseconds. Neither had a source. Both are removed, and the argument above stands without them. Know the ceiling before you start: "DataStore is ideal for small datasets and does not support partial updates or referential integrity." Anything relational belongs in Room, with migrations to match.

Which DataStore should you use: Preferences, JSON or Proto?

The earlier version described two implementations. The documentation now presents three configurations, current as of DataStore 1.2.1 (releases):
PreferencesJSONProto
What you storeKey-value pairsA serializable data classA protocol buffer message
SchemaNoneThe data classA .proto file
Type safetyNoYesYes
Extra setupNoneA SerializerA Serializer and the protobuf plugin
The "No" in the first column corrects this article too, which used to call Preferences DataStore type-safe. Typed keys are a convenience. The documentation's verdict is that Preferences DataStore "does not require a predefined schema, and it does not provide type safety". For typed objects, "You can choose to use Protocol Buffers, JSON or any other serialization strategy", and Google states a preference: "We recommend that you use protocol buffers, which help ensure immutability, a clear API, and efficient serialization." A working rule: Preferences for a handful of independent flags, Proto or JSON as soon as the values form an object.

Preferences DataStore: The Shortest Migration Path

Preferences DataStore is the closest thing to SharedPreferences and needs no schema. Create it with the preferencesDataStore delegate and follow the instruction that comes with it: "Call it once at the top level of your Kotlin file." Reads come from dataStore.data, a Flow you map to the value you want. Writes go through edit, which the reference defines as "Edit the value in DataStore transactionally in an atomic read-modify-write operation" (Android). Read, change and write happen as one step, so two writers cannot interleave.
kotlin
// 1. Create the DataStore once, at the top level of a Kotlin file
val Context.settingsDataStore by preferencesDataStore(
    name = "user_settings"
)

// 2. Define the preference keys
object SettingsKeys {
    val DARK_MODE = booleanPreferencesKey("dark_mode")
    val FONT_SIZE = intPreferencesKey("font_size")
    val USERNAME = stringPreferencesKey("username")
    val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
    val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
}

// 3. Read with Flow
class SettingsRepository(private val context: Context) {

    val userSettings: Flow<UserSettings> = context.settingsDataStore.data
        .map { prefs ->
            UserSettings(
                darkMode = prefs[SettingsKeys.DARK_MODE] ?: false,
                fontSize = prefs[SettingsKeys.FONT_SIZE] ?: 16,
                username = prefs[SettingsKeys.USERNAME] ?: "Guest",
                notificationsEnabled = prefs[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true
            )
        }

    // 4. Write inside a transaction
    suspend fun setDarkMode(enabled: Boolean) {
        context.settingsDataStore.edit { prefs ->
            prefs[SettingsKeys.DARK_MODE] = enabled
        }
    }

    suspend fun updateFontSize(size: Int) {
        context.settingsDataStore.edit { prefs ->
            prefs[SettingsKeys.FONT_SIZE] = size.coerceIn(12, 32)
        }
    }

    // Two values, one transaction: both are written or neither is
    suspend fun completeOnboarding(username: String) {
        context.settingsDataStore.edit { prefs ->
            prefs[SettingsKeys.ONBOARDING_COMPLETE] = true
            prefs[SettingsKeys.USERNAME] = username
        }
    }
}

Proto DataStore: Type-Safe Structured Data

When the values belong together, define them as a message. "Proto DataStore requires a predefined schema in a proto file in the app/src/main/proto/ directory", written in the protocol buffers language, and the build generates the class. You also supply a Serializer, which tells DataStore how to read and write the type and what the default is when no file exists yet. Writes use updateData. It hands you the current value and stores what you return, and "All of the code in the updateData block is treated as a single transaction."
kotlin
// app/src/main/proto/user_preferences.proto
syntax = "proto3";

option java_package = "com.myapp.datastore";
option java_multiple_files = true;

message UserPreferences {
    bool dark_mode = 1;
    int32 font_size = 2;
    string username = 3;
    bool onboarding_complete = 4;

    enum Theme {
        SYSTEM = 0;
        LIGHT = 1;
        DARK = 2;
    }
    Theme theme = 5;
}

// Serializer (required by DataStore)
object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences =
        UserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (e: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", e)
        }
    }

    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
        t.writeTo(output)
    }
}

// Create the DataStore once, at the top level
val Context.userPrefsStore: DataStore<UserPreferences> by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer
)

class UserPrefsRepository(private val context: Context) {

    val preferences: Flow<UserPreferences> = context.userPrefsStore.data

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

    suspend fun updateProfile(username: String, fontSize: Int) {
        context.userPrefsStore.updateData { current ->
            current.toBuilder()
                .setUsername(username)
                .setFontSize(fontSize)
                .build()
        }
    }
}

The Rules That Break DataStore When Ignored

The documentation has a section titled "Use DataStore correctly", and its rules are the ones that turn up in crash reports. 1. One instance per file. "Never create more than one instance of DataStore for a given file in the same process. Doing so can break all DataStore functionality." The failure is loud: "DataStore will throw IllegalStateException when reading or updating data". This is why the delegate lives at the top level of a file and not inside a class or a composable. 2. The stored type must be immutable. "Mutating a type used in DataStore invalidates the consistency that DataStore provides and creates potentially serious, hard-to-catch bugs." A data class with a var or a mutable list inside it breaks this quietly. 3. More than one process needs the multi-process variant. "If you intend to access the DataStore from more than one process, you must use MultiProcessDataStore", available "since the 1.1.0 release". If your app has a sync service in a separate process and a settings screen in the main one, rule 3 is about you.

Migrating from SharedPreferences

Existing users have settings in the old file, and SharedPreferencesMigration carries them over when the DataStore is created (Android). The earlier version of this article said the migration "runs exactly once". The contract says the opposite, and it matters if you write your own mapping: "Methods on this migration (shouldMigrate, migrate and cleanUp) may be called multiple times, so their implementations must be idempotent" (Android). A migration that increments a counter or appends to a list is a bug waiting for one failed write. Three details from the reference: - Keys are not copied twice. "If the key is already present in the new Preferences, the key will not be migrated again." - Only simple types move. "This migration only supports the basic SharedPreferences types: boolean, float, int, long, string and string set." - Leave keysToMigrate unset to take everything. "If keysToMigrate is not set, all keys will be migrated from the existing SharedPreferences." Test it the way you would test a database migration: start from a file written by the old version of the app.
kotlin
// Preferences DataStore with migration
val Context.settingsDataStore by preferencesDataStore(
    name = "user_settings",
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "legacy_settings",
                keysToMigrate = setOf("dark_mode", "font_size", "username")
            )
        )
    }
)

// Proto DataStore with a custom mapping.
// The mapping only sets values, so running it twice gives the same result.
val Context.userPrefsStore: DataStore<UserPreferences> by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "legacy_settings"
            ) { sharedPrefs, currentData ->
                currentData.toBuilder().apply {
                    if (sharedPrefs.contains("dark_mode")) {
                        val dark = sharedPrefs.getBoolean("dark_mode", false)
                        darkMode = dark
                        theme = if (dark) {
                            UserPreferences.Theme.DARK
                        } else {
                            UserPreferences.Theme.LIGHT
                        }
                    }
                    if (sharedPrefs.contains("font_size")) {
                        fontSize = sharedPrefs.getInt("font_size", 16)
                    }
                    if (sharedPrefs.contains("username")) {
                        username = sharedPrefs.getString("username", "") ?: ""
                    }
                }.build()
            }
        )
    }
)

Integrating DataStore with Jetpack Compose

The documentation draws the line clearly: keep DataStore in the data layer, expose it through a ViewModel, and "Avoid reading from or writing to DataStore directly within your composable functions." The ViewModel turns the repository's Flow into a StateFlow with stateIn, and the composable collects it with collectAsStateWithLifecycle(). There is no second copy of the state to keep in sync: a write goes to the store, the store emits, the screen recomposes. Two practical notes. A StateFlow built this way needs a collector in tests or its value never changes, which the coroutines deep dive covers. And every write is a disk transaction, so a slider should save when the drag ends, not on every frame of it.
kotlin
class SettingsViewModel(
    private val settingsRepo: SettingsRepository
) : ViewModel() {

    val uiState: StateFlow<UserSettings> = settingsRepo.userSettings
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UserSettings()
        )

    fun setDarkMode(enabled: Boolean) {
        viewModelScope.launch {
            // No manual state update: the Flow from DataStore emits
            settingsRepo.setDarkMode(enabled)
        }
    }

    fun updateFontSize(newSize: Int) {
        viewModelScope.launch {
            settingsRepo.updateFontSize(newSize)
        }
    }
}

@Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    // Local state while dragging; one write when the drag ends
    var draftSize by remember(state.fontSize) {
        mutableFloatStateOf(state.fontSize.toFloat())
    }

    Column(modifier = Modifier.padding(16.dp)) {
        Switch(
            checked = state.darkMode,
            onCheckedChange = { viewModel.setDarkMode(it) }
        )
        Slider(
            value = draftSize,
            valueRange = 12f..32f,
            onValueChange = { draftSize = it },
            onValueChangeFinished = {
                viewModel.updateFontSize(draftSize.toInt())
            }
        )
        Text(
            text = "Logged in as ${state.username}",
            fontSize = draftSize.sp
        )
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Google now tells SharedPreferences users to move to DataStore, which stores data asynchronously, consistently and transactionally.
  2. 2There are three configurations: Preferences, JSON and Proto. Preferences DataStore has no schema and does not provide type safety.
  3. 3Create one DataStore instance per file, at the top level. A second instance for the same file throws IllegalStateException.
  4. 4The stored type must be immutable, and a second process needs MultiProcessDataStore.
  5. 5A migration can run more than once, so a custom mapping must be idempotent.
  6. 6Keep DataStore out of composables: repository, ViewModel, StateFlow, then collectAsStateWithLifecycle.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Should I use Preferences, JSON or Proto DataStore?

Use Preferences DataStore for a few independent key-value settings. It needs no schema, and the documentation states that it does not provide type safety. Once the values form an object, use a typed DataStore with a Serializer. Google recommends protocol buffers for that, and JSON through kotlinx.serialization is the lighter setup.

How do I migrate from SharedPreferences to DataStore?

Pass a SharedPreferencesMigration to produceMigrations when you create the DataStore. It copies boolean, float, int, long, string and string set values, and it skips a key that already exists in the new store. If you write a custom mapping, make it idempotent, because the migration methods may be called more than once.

Why does DataStore throw IllegalStateException?

Most often because two DataStore instances are active for the same file in one process. The documentation says never to create more than one, and that doing so can break all DataStore functionality. Declare the delegate once at the top level of a Kotlin file and reach it through that property everywhere else.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA