Skip to content
intermediate Phase 7 · Data & Persistence

DataStore Preferences

Store simple key-value data with Preferences DataStore using Kotlin coroutines and Flow.

35m
2 problems
Topic Progress 0%

Why DataStore Over SharedPreferences

SharedPreferences Limitations

SharedPreferences has served Android well but has real problems:

  1. Synchronous API on main threadgetSharedPreferences() does disk I/O. Calling commit() on the main thread causes ANRs.
  2. No error handlinggetString() returns null on failure with no exception.
  3. Not thread-safe by defaultapply() can lose writes during process death.
  4. No type safety — keys are raw strings, values have no compile-time checking.
  5. Blocking I/O — even apply() writes to disk synchronously under the hood, then posts to memory.

DataStore Advantages

  • Fully asynchronous via Kotlin coroutines and Flow
  • Type-safe with compile-time key definitions
  • Transactional — writes are atomic
  • Consistent — no partial writes during process death
  • Two flavors — Preferences DataStore and Proto DataStore

Choosing Between Preferences and Proto

Feature Preferences DataStore Proto DataStore
Data model Key-value pairs Protocol Buffers
Type safety String keys, inferred types Strongly typed
Complexity Simple Moderate
Use case Settings, flags Structured data

Setup

// build.gradle.kts
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Creating the DataStore instance
val Context.dataStore by preferencesDataStore(name = "settings")

Reading and Writing with Coroutines

Defining Keys

object PreferenceKeys {
    val DARK_MODE = booleanPreferencesKey("dark_mode")
    val USERNAME = stringPreferencesKey("username")
    val FONT_SIZE = intPreferencesKey("font_size")
    val LANGUAGE = stringPreferencesKey("language")
}

Keys are compile-time constants — no more typo bugs with raw strings.

Writing Preferences

// In a ViewModel or repository
viewModelScope.launch {
    context.dataStore.edit { prefs ->
        prefs[PreferenceKeys.DARK_MODE] = true
        prefs[PreferenceKeys.USERNAME] = "amazon_developer"
        prefs[PreferenceKeys.FONT_SIZE] = 14
    }
}

edit is atomic. The lambda runs inside a transaction — either all changes apply or none do.

Reading as Flow

val darkModeFlow: Flow<Boolean> = context.dataStore.data
    .catch { exception ->
        if (exception is IOException) emit(emptyPreferences())
        else throw exception
    }
    .map { prefs ->
        prefs[PreferenceKeys.DARK_MODE] ?: false
    }

// Collect in Composable
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
    val isDarkMode by viewModel.darkModeFlow.collectAsState(initial = false)
    
    Switch(
        checked = isDarkMode,
        onCheckedChange = { viewModel.toggleDarkMode(it) }
    )
}

The catch operator handles disk I/O errors gracefully. Without it, an IOException kills the Flow.

Reading Once (Non-Flow)

suspend fun getUsername(): String {
    return context.dataStore.data.first()[PreferenceKeys.USERNAME] ?: "Guest"
}

first() collects the latest value and cancels. Use this for one-shot reads, not ongoing observation.

Clearing Preferences

context.dataStore.edit { it.clear() }

This removes all keys in a single transaction.

Migrating from SharedPreferences

Migration Strategy

Android provides SharedPreferencesMigration to automatically move data during the first DataStore read.

val Context.dataStore by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(SharedPreferencesMigration(context, "old_prefs_name"))
    }
)

When the DataStore is first accessed, it reads all keys from the old SharedPreferences file and writes them into the new DataStore. After migration, the old file is not deleted — handle that yourself after verifying the migration worked.

Manual Migration

For complex scenarios where key names changed or values need transformation:

suspend fun migratePreferences(dataStore: DataStore<Preferences>) {
    val oldPrefs = context.getSharedPreferences("old_prefs", Context.MODE_PRIVATE)
    
    dataStore.edit { newPrefs ->
        // Map old key to new key
        oldPrefs.getString("theme", null)?.let {
            newPrefs[PreferenceKeys.DARK_MODE] = (it == "dark")
        }
        
        // Transform value
        oldPrefs.getInt("text_size", 12).let {
            newPrefs[PreferenceKeys.FONT_SIZE] = it + 2
        }
    }
    
    // Delete old file after successful migration
    File(oldPrefs.fileParent, oldPrefs.file.name).delete()
}

Best Practices

  • Never access DataStore before ViewModel init — use by lazy or dependency injection
  • Handle IOException in the catch operator of every Flow read
  • Use first() for one-shot reads, not first().value which can block
  • Test migration by verifying all expected keys transfer with correct values
  • Don't use DataStore on the main thread for direct reads — always use coroutines

Quiz

1. What is the primary advantage of DataStore Preferences over SharedPreferences?

Question 1 options

2. How do you read a preference value once as a one-shot operation?

Question 2 options

3. What happens if you call dataStore.edit without error handling when disk I/O fails?

Question 3 options

4. Which function handles errors when reading from DataStore as a Flow?

Question 4 options

Flashcards

Question

What does `preferencesDataStore` delegate do?

Answer

Creates and stores a single DataStore<Preferences> instance per Context. The DataStore is tied to the application lifecycle.

Question

How do you define a type-safe preference key?

Answer

Use stringPreferencesKey(), intPreferencesKey(), booleanPreferencesKey(), or doublePreferencesKey() factory functions.

Question

Why must you catch IOExceptions in DataStore Flows?

Answer

Disk I/O operations can fail due to storage issues. Without catch, the Flow terminates and the UI may crash or show stale data.

Question

Is the edit operation in DataStore atomic?

Answer

Yes. The edit lambda runs in a transaction — either all changes apply or none do, preventing partial writes.

Revision Notes

Key Takeaways

  • 1. DataStore uses coroutines and Flow — no blocking I/O on the main thread
  • 2. All writes via edit() are atomic transactions
  • 3. Always handle IOException in the catch operator when reading as Flow
  • 4. Use first() for one-shot reads, map{} for continuous observation

Interview Tips

  • Explain why SharedPreferences is problematic (synchronous, not thread-safe, no type safety)
  • Discuss the atomicity guarantee of DataStore edit operations
  • Know when to use first() vs collect — first for one-shot, collect for ongoing observation
  • Be ready to describe the migration strategy from SharedPreferences to DataStore

Cheat Sheet

DataStore Preferences Cheat Sheet

Setup:

val Context.dataStore by preferencesDataStore(name = "settings")

Key Definition:

val DARK_MODE = booleanPreferencesKey("dark_mode")

Write:

context.dataStore.edit { it[DARK_MODE] = true }

Read Flow:

context.dataStore.data.map { it[DARK_MODE] ?: false }

Read Once:

context.dataStore.data.first()

Migration:

preferencesDataStore(produceMigrations = { listOf(SharedPreferencesMigration(ctx, "old")) })

Error Handling: Always catch IOException in Flows, emit emptyPreferences() as fallback.