Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 034 / 100

DataStore for preferences: practical tips and trade-offs

Replace SharedPreferences.

Expona Admin7 min read

DataStore for preferences: practical tips and trade-offs

SharedPreferences has served Android teams well for over a decade, but it has fundamental limitations: it's synchronous on the main thread, offers no type safety, and lacks atomic multi-key operations. DataStore for preferences: practical tips and trade-offs is the modern replacement that Google recommends, giving us async-first design with Flow integration and structured data support. In this guide, we'll walk through both Preferences DataStore and Proto DataStore, show you how to handle async patterns in production code, and cover a safe migration path from SharedPreferences.

Preferences DataStore for key-value

Preferences DataStore is the direct replacement for SharedPreferences when you need simple key-value storage. Unlike SharedPreferences, it's built on coroutines and suspending functions, so you never block the main thread. The API feels familiar but enforces async patterns from the start.

To get started, add the dependency:

kotlin
dependencies {
    implementation "androidx.datastore:datastore-preferences:1.0.0"
}

Create a singleton DataStore in your codebase:

kotlin
val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "user_preferences"
)

object PreferencesKeys {
    val IS_DARK_MODE = booleanPreferencesKey("is_dark_mode")
    val USER_ID = stringPreferencesKey("user_id")
    val NOTIFICATION_COUNT = intPreferencesKey("notification_count")
}

Reading data is done via a Flow, which means subscribers get updates whenever the value changes:

kotlin
class SettingsRepository(private val context: Context) {
    val isDarkModeFlow: Flow<Boolean> = context.userPreferencesDataStore.data
        .catch { exception ->
            if (exception is IOException) {
                emit(emptyPreferences())
            } else {
                throw exception
            }
        }
        .map { preferences ->
            preferences[PreferencesKeys.IS_DARK_MODE] ?: false
        }

    suspend fun setDarkMode(enabled: Boolean) {
        context.userPreferencesDataStore.edit { preferences ->
            preferences[PreferencesKeys.IS_DARK_MODE] = enabled
        }
    }
}

In your UI layer, collect the Flow with repeatOnLifecycle to respect lifecycle:

kotlin
class SettingsFragment : Fragment() {
    private val viewModel: SettingsViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.isDarkMode.collect { isDark ->
                    applyTheme(isDark)
                }
            }
        }
    }
}

The key trade-off with Preferences DataStore: while it's async and safe, it adds a small latency compared to SharedPreferences (which reads from memory after initialization). For most settings like theme, language, or notification flags, this latency is imperceptible. For frequently accessed data that must be available immediately, consider caching the latest value in a ViewModel or keeping a small in-memory copy.

Proto DataStore for typed data

When your data becomes more complex—nested objects, lists, enums—Preferences DataStore becomes unwieldy. Proto DataStore uses Protocol Buffers to serialize structured data safely. This is our go-to for user profiles, feature flags, and cached API responses that need to survive app restarts.

First, define your proto schema:

kotlin
// user_settings.proto
syntax = "proto3";

message UserSettings {
    string user_id = 1;
    string theme = 2;  // "light" or "dark"
    repeated string favorite_categories = 3;
    int32 theme_color_index = 4;
}

Add the protobuf plugin to your build.gradle:

kotlin
plugins {
    id "com.google.protobuf" version "0.9.1"
}

dependencies {
    implementation "androidx.datastore:datastore:1.0.0"
    implementation "com.google.protobuf:protobuf-kotlin-lite:3.21.4"
}

Create a DataStore serializer:

kotlin
class UserSettingsSerializer : Serializer<UserSettings> {
    override val defaultValue: UserSettings = UserSettings.getDefaultInstance()

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

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

val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.pb",
    serializer = UserSettingsSerializer()
)

Using Proto DataStore is similarly straightforward:

kotlin
class UserRepository(private val context: Context) {
    val userSettingsFlow: Flow<UserSettings> = context.userSettingsDataStore.data
        .catch { exception ->
            if (exception is CorruptionException) {
                emit(UserSettings.getDefaultInstance())
            } else {
                throw exception
            }
        }

    suspend fun updateFavoriteCategories(categories: List<String>) {
        context.userSettingsDataStore.updateData { currentSettings ->
            currentSettings.toBuilder()
                .clearFavoriteCategories()
                .addAllFavoriteCategories(categories)
                .build()
        }
    }

    suspend fun setTheme(theme: String) {
        context.userSettingsDataStore.updateData { it.toBuilder().setTheme(theme).build() }
    }
}

Proto DataStore automatically handles versioning and schema evolution—if you add new fields with defaults, old data deserializes gracefully. This makes it safer than custom JSON serialization for long-term maintenance.

Async with Flow

DataStore's core strength is Flow integration. Every read operation returns a Flow, which means your UI automatically stays in sync with persisted data without polling or manual refresh logic.

In a ViewModel, map the Flow to your UI state:

kotlin
class SettingsViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
    val uiState: StateFlow<SettingsUiState> = userRepository.userSettingsFlow
        .map { settings ->
            SettingsUiState.Success(
                isDarkMode = settings.theme == "dark",
                favoriteCategories = settings.favoriteCategories.toImmutableList()
            )
        }
        .catch { emit(SettingsUiState.Error(it.message ?: "Unknown error")) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = SettingsUiState.Loading
        )

    fun setDarkMode(enabled: Boolean) {
        viewModelScope.launch {
            userRepository.setTheme(if (enabled) "dark" else "light")
        }
    }
}

One important pattern: use SharingStarted.WhileSubscribed with a timeout (typically 5000ms) rather than Eagerly. This allows the Flow to stop consuming resources when your screen is off, then reactivate when you return. With Eagerly, DataStore keeps deserializing from disk unnecessarily.

If you need a one-time read instead of a Flow, use suspend directly:

kotlin
suspend fun getUserId(): String? {
    return context.userSettingsDataStore.data.firstOrNull()?.userId
}

However, prefer Flow for UI-bound data. One-time reads are best for initialization or non-UI coroutines.

Migrate from SharedPreferences

Moving a production app from SharedPreferences to DataStore requires care to avoid data loss. The safest approach is a two-stage migration: read from SharedPreferences in parallel while writing to DataStore, then cut over once you're confident.

Create a migration helper:

kotlin
class SharedPreferencesToDataStoreMigration(
    private val context: Context
) {
    suspend fun migrateIfNeeded() {
        val sharedPrefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
        val migrated = context.userPreferencesDataStore.data.first().containsKey(
            PreferencesKeys.MIGRATION_COMPLETE
        )

        if (migrated) return

        context.userPreferencesDataStore.edit { preferences ->
            sharedPrefs.all.forEach { (key, value) ->
                when (value) {
                    is Boolean -> preferences[booleanPreferencesKey(key)] = value
                    is String -> preferences[stringPreferencesKey(key)] = value
                    is Int -> preferences[intPreferencesKey(key)] = value
                    // handle other types
                }
            }
            preferences[PreferencesKeys.MIGRATION_COMPLETE] = true
        }
    }
}

Run the migration early in your app startup, on the IO dispatcher:

kotlin
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch(Dispatchers.IO) {
            SharedPreferencesToDataStoreMigration(this@MainActivity).migrateIfNeeded()
        }
        // ... rest of onCreate
    }
}

Once you've verified that data migrated correctly in production (check logs or analytics for a week), you can safely remove SharedPreferences usage and the migration code. Don't rush this step—incomplete migrations are a frequent source of support tickets.

Common mistakes

  • Ignoring CorruptionException: Always catch CorruptionException in your Flow operators and emit a safe default. Don't just crash the app if a proto file is corrupted. Wrap with .catch { exception -> if (exception is CorruptionException) emit(defaultValue) else throw it }.
  • Using Eagerly instead of WhileSubscribed: SharingStarted.Eagerly keeps the Flow active and disk reads happening even when your app is backgrounded. Use WhileSubscribed(timeoutMillis) for cleaner resource management.
  • Blocking on the main thread: Never call runBlocking on a DataStore read. If you need synchronous access in a non-coroutine context, consider caching the latest value in a ViewModel or Application singleton instead.
  • Not handling initial state: When using stateIn, always provide an initialValue. Otherwise, your UI shows nothing until the first collection completes, creating flickering.
  • Over-migrating: Don't migrate vast amounts of data from SharedPreferences at once. If you have hundreds of keys, consider a gradual approach or splitting into multiple DataStores.

Key takeaways

  • Preferences DataStore replaces SharedPreferences with async-first, coroutine-safe APIs that never block the main thread.
  • Proto DataStore adds type safety and schema versioning for complex data, making it better than JSON serialization for long-term maintenance.
  • Always consume DataStore as a Flow in your UI layer; use stateIn with WhileSubscribed to manage resource lifecycle correctly.
  • Migrate from SharedPreferences gradually, running both in parallel, then cutting over once you've verified data integrity in production.
  • Handle CorruptionException gracefully—emit a sensible default rather than crashing when a proto file is unreadable.
  • For small, read-heavy preference values, keep a tiny in-memory cache in your ViewModel to avoid repeated Flow collection overhead on the same screen.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles