DataStore — Preferences & Proto
DataStore is the modern replacement for SharedPreferences. Two variants:
- Preferences DataStore — key-value, no schema. Drop-in replacement for SharedPreferences with coroutine-native, type-safe reads.
- Proto DataStore — typed schema via Protobuf. Single source of truth for complex preferences.
Both are built on coroutines + Flow, with atomic writes and no
deadlocks. If you're still using SharedPreferences, migrate.
Why SharedPreferences is broken
The legacy pain
- apply() is asynchronous but gives no signal when done
- commit() blocks the caller (main thread hazard)
- Reads are synchronous — block on first access
- Strong-keyed but not strongly typed
- No schema — rename a key, lose the data
- Not coroutine-native
The modern replacement
- All writes suspend and return when durable
- Reads exposed as Flow<Preferences>
- Coroutine-safe by design — no ANRs
- Proto variant gives full type safety + schema
- Atomic writes (file-level rename)
- Integrates cleanly with ViewModel + Compose
Preferences DataStore
Setup
// libs.versions.toml
datastore = "1.1.1"
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
// Application-scoped DataStore (recommended)
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "legacy_prefs"))
}
)
Hilt wiring
@Module @InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides @Singleton
fun provideSettingsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.settingsDataStore
}
Reading
class SettingsRepository @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private object Keys {
val THEME = stringPreferencesKey("theme")
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val FONT_SCALE = floatPreferencesKey("font_scale")
val FAVORITE_TAGS = stringSetPreferencesKey("favorite_tags")
}
val theme: Flow<Theme> = dataStore.data
.catch { e ->
if (e is IOException) emit(emptyPreferences()) else throw e
}
.map { prefs ->
when (prefs[Keys.THEME]) {
"dark" -> Theme.Dark
"light" -> Theme.Light
else -> Theme.System
}
}
val allSettings: Flow<Settings> = dataStore.data.map { prefs ->
Settings(
theme = prefs[Keys.THEME]?.let(Theme::valueOf) ?: Theme.System,
notificationsEnabled = prefs[Keys.NOTIFICATIONS_ENABLED] ?: true,
fontScale = prefs[Keys.FONT_SCALE] ?: 1.0f,
favoriteTags = prefs[Keys.FAVORITE_TAGS] ?: emptySet()
)
}
suspend fun setTheme(theme: Theme) {
dataStore.edit { prefs -> prefs[Keys.THEME] = theme.name }
}
suspend fun setNotifications(enabled: Boolean) {
dataStore.edit { prefs -> prefs[Keys.NOTIFICATIONS_ENABLED] = enabled }
}
}
Key types
| Helper | Stores |
|---|---|
intPreferencesKey | Int |
longPreferencesKey | Long |
floatPreferencesKey | Float |
doublePreferencesKey | Double |
booleanPreferencesKey | Boolean |
stringPreferencesKey | String |
stringSetPreferencesKey | Set<String> |
byteArrayPreferencesKey (1.1+) | ByteArray |
For anything else (lists, maps, complex objects), use Proto DataStore — don't JSON-encode into a string key.
Proto DataStore
When to use
- Complex user settings (notification preferences per category, nested objects)
- Schema versioning (add a new field; migrate existing data automatically)
- Strongly-typed preferences across the codebase
- Shared models with a backend over the wire (same .proto file)
Define the schema
// src/main/proto/user_preferences.proto
syntax = "proto3";
option java_package = "com.myapp.data.prefs";
option java_multiple_files = true;
message UserPreferences {
Theme theme = 1;
bool notifications_enabled = 2;
float font_scale = 3;
repeated string favorite_tags = 4;
NotificationSettings notification_settings = 5;
enum Theme {
SYSTEM = 0;
LIGHT = 1;
DARK = 2;
}
}
message NotificationSettings {
bool messages = 1;
bool mentions = 2;
bool promotions = 3;
int32 quiet_hours_start = 4; // minutes since midnight
int32 quiet_hours_end = 5;
}
Build config
plugins {
id("com.google.protobuf") version "0.9.4"
}
protobuf {
protoc { artifact = "com.google.protobuf:protoc:4.28.0" }
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") { option("lite") }
create("kotlin") { option("lite") }
}
}
}
}
dependencies {
implementation("androidx.datastore:datastore:1.1.1")
implementation("com.google.protobuf:protobuf-kotlin-lite:4.28.0")
}
Serializer
object UserPreferencesSerializer : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.newBuilder()
.setTheme(UserPreferences.Theme.SYSTEM)
.setNotificationsEnabled(true)
.setFontScale(1.0f)
.setNotificationSettings(
NotificationSettings.newBuilder()
.setMessages(true)
.setMentions(true)
.setPromotions(false)
.build()
)
.build()
override suspend fun readFrom(input: InputStream): UserPreferences = try {
UserPreferences.parseFrom(input)
} catch (e: InvalidProtocolBufferException) {
throw CorruptionException("Unable to read UserPreferences", e)
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
t.writeTo(output)
}
}
DataStore instance
val Context.userPreferencesDataStore: DataStore<UserPreferences> by dataStore(
fileName = "user_preferences.pb",
serializer = UserPreferencesSerializer,
corruptionHandler = ReplaceFileCorruptionHandler { UserPreferencesSerializer.defaultValue }
)
Repository
class UserPreferencesRepository @Inject constructor(
private val dataStore: DataStore<UserPreferences>
) {
val preferences: Flow<UserPreferences> = dataStore.data.catch { e ->
if (e is IOException) emit(UserPreferencesSerializer.defaultValue) else throw e
}
suspend fun setTheme(theme: UserPreferences.Theme) {
dataStore.updateData { current -> current.toBuilder().setTheme(theme).build() }
}
suspend fun toggleMessages() {
dataStore.updateData { current ->
current.toBuilder().setNotificationSettings(
current.notificationSettings.toBuilder()
.setMessages(!current.notificationSettings.messages)
.build()
).build()
}
}
suspend fun setQuietHours(start: Int, end: Int) {
dataStore.updateData { current ->
current.toBuilder().setNotificationSettings(
current.notificationSettings.toBuilder()
.setQuietHoursStart(start)
.setQuietHoursEnd(end)
.build()
).build()
}
}
}
Schema evolution
Proto handles schema evolution gracefully:
- Adding fields — default values for new fields in old data
- Removing fields — old clients ignore new data; old data keeps working
- Renaming fields — avoid; use field numbers as the canonical identity
- Changing field types — breaking; version your proto file
For breaking changes, write a migration:
val Context.userPreferencesDataStore: DataStore<UserPreferences> by dataStore(
fileName = "user_preferences.pb",
serializer = UserPreferencesSerializer,
produceMigrations = { listOf(UserPrefsV1toV2Migration) }
)
object UserPrefsV1toV2Migration : DataMigration<UserPreferences> {
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
currentData.schemaVersion < 2
override suspend fun migrate(currentData: UserPreferences): UserPreferences =
currentData.toBuilder()
.setSchemaVersion(2)
.setFontScale(if (currentData.fontScale == 0f) 1.0f else currentData.fontScale)
.build()
override suspend fun cleanUp() { /* no-op */ }
}
Migrating from SharedPreferences
Preferences DataStore — automatic migration
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "legacy_prefs"))
}
)
SharedPreferencesMigration copies every key from the legacy SharedPreferences
into DataStore on first access, then deletes the SharedPreferences file.
Zero code changes to callers.
Proto DataStore — custom migration
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
produceSharedPreferences = { context.getSharedPreferences("legacy_prefs", 0) }
) { prefs, currentData ->
currentData.toBuilder()
.setTheme(UserPreferences.Theme.valueOf(prefs.getString("theme", "SYSTEM") ?: "SYSTEM"))
.setNotificationsEnabled(prefs.getBoolean("notifications", true))
.build()
}
)
}
Multi-process considerations
DataStore is not multi-process safe by default. If you read/write from
multiple processes (main app + a :sync process, widget remote views):
- Option A — use a
ContentProviderwrapper to route all writes through one process. - Option B — use the preview
MultiProcessDataStore(available asandroidx.datastore:datastore-core:1.1+with experimental API).
For most apps, a single-process DataStore is correct.
Testing DataStore
@RunWith(AndroidJUnit4::class)
class SettingsRepositoryTest {
private val testContext = ApplicationProvider.getApplicationContext<Context>()
private lateinit var dataStore: DataStore<Preferences>
private lateinit var repository: SettingsRepository
private val scope = TestScope(UnconfinedTestDispatcher())
@Before fun setup() {
dataStore = PreferenceDataStoreFactory.create(
scope = scope,
produceFile = { testContext.preferencesDataStoreFile("test-settings") }
)
repository = SettingsRepository(dataStore)
}
@After fun tearDown() {
testContext.preferencesDataStoreFile("test-settings").delete()
}
@Test fun writes_and_reads_back_theme() = scope.runTest {
repository.setTheme(Theme.Dark)
val observed = repository.theme.first()
assertEquals(Theme.Dark, observed)
}
}
For Proto DataStore, use DataStoreFactory.create with your serializer.
Using DataStore in Compose
@Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
val prefs by viewModel.preferences.collectAsStateWithLifecycle(initialValue = UserPreferences.getDefaultInstance())
val scope = rememberCoroutineScope()
Column {
SwitchSetting(
label = "Messages",
checked = prefs.notificationSettings.messages,
onCheckedChange = { scope.launch { viewModel.toggleMessages() } }
)
}
}
collectAsStateWithLifecycle suspends collection when the screen is off —
respect battery.
Sync with backend
For settings that should follow a user across devices, mirror DataStore to your backend:
class SettingsSyncWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val repo: UserPreferencesRepository,
private val api: SettingsApi
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = runCatching {
val local = repo.preferences.first()
val remote = api.fetchSettings()
val merged = merge(local, remote)
repo.updateAll(merged)
api.putSettings(merged)
Result.success()
}.getOrElse { Result.retry() }
}
Use a version/timestamp on the proto for conflict resolution. See Offline-First Architecture.
Preferences vs Proto — how to pick
| Use Preferences DataStore when... | Use Proto DataStore when... |
|---|---|
| < 10 simple settings | Complex nested preferences |
| Replacing SharedPreferences directly | Need schema evolution |
| Quick prototyping | Cross-platform (shared proto with backend) |
| Don't care about runtime type safety | Type safety matters |
| No complex relationships | Many related settings grouped |
For new features in an established app, default to Proto — the up-front schema file pays off every time you add a field.
Common anti-patterns
DataStore mistakes
- Creating multiple DataStore instances for the same file
- Calling edit { } every character during text input
- Storing JSON-encoded complex objects in Preferences
- Not handling IOException from .data
- Writing to DataStore from main thread (non-suspend wrappers)
- Large binary blobs in DataStore
Solid DataStore
- One @Singleton DataStore per file via Hilt
- Debounce or batch writes during rapid input
- Use Proto DataStore for complex data
- .catch { if (it is IOException) emit(...) }
- All writes via suspend functions
- Use Room or file storage for blobs > 1MB
Key takeaways
Practice exercises
- 01
Migrate from SharedPreferences
Pick a SharedPreferences instance in your app. Replace with Preferences DataStore using SharedPreferencesMigration. Verify existing values carry over.
- 02
Design a Proto schema
Model your user settings as a .proto file. Include an enum, nested message, and repeated field. Generate bindings and build a UserPreferencesRepository.
- 03
Write a migration
Add a field to your Proto schema. Write a DataMigration that backfills the new field on upgrade. Test with an existing store on disk.
- 04
Sync with backend
Create a SettingsSyncWorker that merges local DataStore with a remote API on connectivity. Handle conflicts with last-write-wins.
Next
Continue to Encrypted Storage for Keystore-backed secrets or Files, MediaStore & Scoped Storage for larger data.