Why move off SharedPreferences?
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?
| Preferences | JSON | Proto | |
|---|---|---|---|
| What you store | Key-value pairs | A serializable data class | A protocol buffer message |
| Schema | None | The data class | A .proto file |
| Type safety | No | Yes | Yes |
| Extra setup | None | A Serializer | A Serializer and the protobuf plugin |
Preferences DataStore: The Shortest Migration Path
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.// 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
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."// 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
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
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.// 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
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.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
)
}
}Key Takeaways
- 1Google now tells SharedPreferences users to move to DataStore, which stores data asynchronously, consistently and transactionally.
- 2There are three configurations: Preferences, JSON and Proto. Preferences DataStore has no schema and does not provide type safety.
- 3Create one DataStore instance per file, at the top level. A second instance for the same file throws IllegalStateException.
- 4The stored type must be immutable, and a second process needs MultiProcessDataStore.
- 5A migration can run more than once, so a custom mapping must be idempotent.
- 6Keep DataStore out of composables: repository, ViewModel, StateFlow, then collectAsStateWithLifecycle.
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.
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.