Jetpack DataStore: The Modern Replacement for SharedPreferences

Migrate from SharedPreferences to DataStore with type-safe Proto DataStore and reactive Preferences DataStore.

Introduction

SharedPreferences has fundamental flaws: no type safety, blocking get(), and no error handling. DataStore fixes these issues with Kotlin coroutines, Flow, and type-safe data. This guide covers migration and production patterns.

Preferences DataStore Basics

Create DataStore with preferencesDataStore delegate. Define keys with stringPreferencesKey. Read with data Flow. Write with edit suspend function. Preferences DataStore is direct SharedPreferences replacement.

Proto DataStore for Type Safety

Define schema with protobuf. Generate Kotlin classes. Create DataStoreFactory with Schema. Get compile-time type safety. Proto DataStore is preferred for complex data.

Migration from SharedPreferences

Use SharedPreferencesMigration helper. Migrate keys one-to-one or transform during migration. Test migration thoroughly. Keep SharedPreferences until migration complete.

Error Handling

DataStore throws IOException on read/write failures. Use catch operator on data Flow. Implement retry logic. Consider fallback values for critical preferences.

Frequently Asked Questions

Should I use Preferences or Proto DataStore?

Preferences for simple key-value storage. Proto for complex, structured data. Proto provides type safety and is worth the setup for production apps.

How do I handle migration?

Use DataStore's built-in migration helper. Map SharedPreferences keys to DataStore keys. Transform data types if needed. Test migration with old app data.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:40
UP00:01
MOD · ARTICLE · DATA LAYERS/N · AX-JETPACPUBLISHED
Data LayerMar 12, 202611 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 SharedPreferences Must Go

SharedPreferences has been part of Android since API level 1. For over 15 years, developers have relied on it for simple key-value storage. But its limitations have become unacceptable in modern apps: - **Not thread-safe for writes**: `apply()` can lose data during concurrent writes. In a 2024 audit, 11% of SharedPreferences corruption reports traced to race conditions between the main thread and background workers. - **Blocks the UI thread**: `getString()` and other read methods perform disk I/O synchronously. On lower-end devices, this adds 8-30ms of jank per read. - **No type safety**: Everything is stored as primitives or Sets of Strings. Complex objects require manual serialization that is error-prone and unversioned. - **No observation**: There is no built-in way to reactively observe changes. `OnSharedPreferenceChangeListener` is unreliable and leaks memory when not unregistered. - **No migration story**: Changing the schema (renaming a key, changing a type) requires manual migration code that is typically untested. Jetpack DataStore solves all five problems. It provides two implementations: Preferences DataStore (key-value, similar to SharedPreferences but async and safe) and Proto DataStore (typed schemas with Protocol Buffers). Both are built on Kotlin coroutines and Flow, making them reactive by default.

Preferences DataStore: Drop-In Replacement

Preferences DataStore is the fastest migration path from SharedPreferences. It uses typed key objects instead of string keys, reads/writes asynchronously via coroutines, and exposes data as a Flow for reactive observation. No Protocol Buffers setup required. The API enforces correct usage by design: reads return Flow (not blocking), writes require a suspend transaction, and keys are typed to prevent stringly-typed errors.
kotlin
// 1. Create the DataStore instance (top-level, one per file)
val Context.settingsDataStore by preferencesDataStore(
    name = "user_settings"
)

// 2. Define typed 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")
    val LAST_SYNC_TIMESTAMP = longPreferencesKey("last_sync_timestamp")
}

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

    val darkModeEnabled: Flow<Boolean> = context.settingsDataStore.data
        .map { prefs -> prefs[SettingsKeys.DARK_MODE] ?: false }

    val fontSize: Flow<Int> = context.settingsDataStore.data
        .map { prefs -> prefs[SettingsKeys.FONT_SIZE] ?: 16 }

    // Combine multiple preferences into a domain object
    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 with suspend transactions
    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)
        }
    }

    suspend fun completeOnboarding(username: String) {
        context.settingsDataStore.edit { prefs ->
            prefs[SettingsKeys.ONBOARDING_COMPLETE] = true
            prefs[SettingsKeys.USERNAME] = username
        }
    }
}

Proto DataStore: Type-Safe Structured Data

For structured data with multiple related fields, Proto DataStore provides full type safety through Protocol Buffers. You define your schema in a `.proto` file, and the build system generates type-safe Kotlin classes. Reads and writes operate on strongly-typed objects, not string keys. Proto DataStore is ideal for user profiles, app configuration, feature flags, and any data that has a natural object structure. The schema is versioned, so migrations are explicit and testable.
kotlin
// 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;
    bool notifications_enabled = 5;
    int64 last_sync_timestamp = 6;

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

// 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
val Context.userPrefsStore: DataStore<UserPreferences> by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer
)

// Repository with type-safe access
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()
        }
    }
}

Migrating from SharedPreferences

DataStore provides a built-in migration mechanism that reads existing SharedPreferences data, maps it to DataStore format, and deletes the old file. The migration runs exactly once, automatically, the first time DataStore is read after installation. This is critical for existing apps: you cannot simply delete SharedPreferences and start fresh because users have existing settings that must be preserved.
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",    // Will map to SettingsKeys.DARK_MODE
                    "font_size",    // Will map to SettingsKeys.FONT_SIZE
                    "username"      // Will map to SettingsKeys.USERNAME
                )
            )
        )
    }
)

// Proto DataStore with custom migration logic
val Context.userPrefsStore: DataStore<UserPreferences> by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "legacy_settings"
            ) { sharedPrefs, currentData ->
                // Custom mapping from SharedPreferences to Proto
                currentData.toBuilder().apply {
                    if (sharedPrefs.contains("dark_mode")) {
                        darkMode = sharedPrefs.getBoolean("dark_mode", false)
                    }
                    if (sharedPrefs.contains("font_size")) {
                        fontSize = sharedPrefs.getInt("font_size", 16)
                    }
                    if (sharedPrefs.contains("username")) {
                        username = sharedPrefs.getString("username", "") ?: ""
                    }
                    // Migrate computed values
                    if (sharedPrefs.contains("dark_mode")) {
                        theme = if (sharedPrefs.getBoolean("dark_mode", false)) {
                            UserPreferences.Theme.DARK
                        } else {
                            UserPreferences.Theme.LIGHT
                        }
                    }
                }.build()
            }
        )
    }
)

Integrating DataStore with Jetpack Compose

DataStore's Flow-based API integrates seamlessly with Compose. The pattern is simple: the ViewModel collects DataStore flows and exposes them as StateFlows. Composables observe these via `collectAsStateWithLifecycle()`, and user actions dispatch suspend functions to update the store. This creates a fully reactive pipeline: DataStore change -> Flow emission -> ViewModel StateFlow update -> Compose recomposition. No manual observation setup, no callback registration, no lifecycle bugs.
kotlin
class SettingsViewModel(
    private val settingsRepo: SettingsRepository
) : ViewModel() {

    val uiState: StateFlow<SettingsUiState> = settingsRepo.userSettings
        .map { prefs ->
            SettingsUiState(
                darkMode = prefs.darkMode,
                fontSize = prefs.fontSize,
                username = prefs.username,
                notificationsEnabled = prefs.notificationsEnabled
            )
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = SettingsUiState()
        )

    fun toggleDarkMode() {
        viewModelScope.launch {
            val current = uiState.value.darkMode
            settingsRepo.setDarkMode(!current)
            // No need to manually update uiState --
            // the Flow from DataStore will emit automatically
        }
    }

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

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

    Column(modifier = Modifier.padding(16.dp)) {
        SwitchRow(
            label = "Dark Mode",
            checked = state.darkMode,
            onCheckedChange = { viewModel.toggleDarkMode() }
        )
        SliderRow(
            label = "Font Size: ${state.fontSize}sp",
            value = state.fontSize.toFloat(),
            valueRange = 12f..32f,
            onValueChange = { viewModel.updateFontSize(it.toInt()) }
        )
        Text(
            text = "Logged in as ${state.username}",
            fontSize = state.fontSize.sp
        )
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1SharedPreferences blocks the UI thread, loses data on concurrent writes, and provides no type safety -- DataStore fixes all three.
  2. 2Use Preferences DataStore for simple key-value pairs and Proto DataStore for structured objects with schemas.
  3. 3DataStore reads return Flow (reactive, non-blocking) and writes use suspend functions (coroutine-safe).
  4. 4Built-in SharedPreferencesMigration preserves existing user data during the migration with zero user impact.
  5. 5Proto DataStore schemas are versioned through Protocol Buffers, making migrations explicit and testable.
  6. 6The DataStore -> Flow -> StateFlow -> Compose pipeline creates a fully reactive settings architecture with no manual observation.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

Should I use Preferences or Proto DataStore?

Preferences for simple key-value storage. Proto for complex, structured data. Proto provides type safety and is worth the setup for production apps.

How do I handle migration?

Use DataStore's built-in migration helper. Map SharedPreferences keys to DataStore keys. Transform data types if needed. Test migration with old app data.

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