Skip to main content

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

SharedPreferences

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
DataStore

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

HelperStores
intPreferencesKeyInt
longPreferencesKeyLong
floatPreferencesKeyFloat
doublePreferencesKeyDouble
booleanPreferencesKeyBoolean
stringPreferencesKeyString
stringSetPreferencesKeySet<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 ContentProvider wrapper to route all writes through one process.
  • Option B — use the preview MultiProcessDataStore (available as androidx.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 settingsComplex nested preferences
Replacing SharedPreferences directlyNeed schema evolution
Quick prototypingCross-platform (shared proto with backend)
Don't care about runtime type safetyType safety matters
No complex relationshipsMany 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

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
Best practices

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

  1. 01

    Migrate from SharedPreferences

    Pick a SharedPreferences instance in your app. Replace with Preferences DataStore using SharedPreferencesMigration. Verify existing values carry over.

  2. 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.

  3. 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.

  4. 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.