Why DataStore Over SharedPreferences
SharedPreferences Limitations
SharedPreferences has served Android well but has real problems:
- Synchronous API on main thread —
getSharedPreferences()does disk I/O. Callingcommit()on the main thread causes ANRs. - No error handling —
getString()returns null on failure with no exception. - Not thread-safe by default —
apply()can lose writes during process death. - No type safety — keys are raw strings, values have no compile-time checking.
- 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 lazyor dependency injection - Handle IOException in the
catchoperator of every Flow read - Use
first()for one-shot reads, notfirst().valuewhich 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?
2. How do you read a preference value once as a one-shot operation?
3. What happens if you call dataStore.edit without error handling when disk I/O fails?
4. Which function handles errors when reading from DataStore as a Flow?
Flashcards
Question
What does `preferencesDataStore` delegate do?
Click to reveal answer
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?
Click to reveal answer
Answer
Use stringPreferencesKey(), intPreferencesKey(), booleanPreferencesKey(), or doublePreferencesKey() factory functions.
Question
Why must you catch IOExceptions in DataStore Flows?
Click to reveal answer
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?
Click to reveal answer
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.