Proto DataStore Fundamentals
Why Proto DataStore
Preferences DataStore stores flat key-value pairs. Proto DataStore stores strongly typed data using Protocol Buffers — ideal when you need structured settings like a user profile or app configuration with nested fields.
Define the Schema
Create src/main/proto/user_settings.proto:
syntax = "proto3";
package com.example.app;
option java_package = "com.example.app";
option java_multiple_files = true;
message UserSettings {
string username = 1;
bool dark_mode = 2;
int32 font_size = 3;
Theme theme = 4;
repeated string favorite_categories = 5;
enum Theme {
LIGHT = 0;
DARK = 1;
SYSTEM = 2;
}
}
Proto fields use numeric tags, not names. Changing tags breaks serialization. Reuse removed tags with [deprecated = true] instead of reusing their numbers.
Build Configuration
// build.gradle.kts
plugins {
id("com.google.protobuf") version "0.9.4"
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.1"
}
}
implementation("androidx.datastore:datastore:1.1.1")
implementation("com.google.protobuf:protobuf-kotlin-lite:3.25.1")
Create the Serializer
object UserSettingsSerializer : Serializer<UserSettings> {
override val defaultValue: UserSettings = UserSettings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserSettings {
return try {
UserSettings.parseFrom(input)
} catch (e: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto", e)
}
}
override suspend fun writeTo(t: UserSettings, output: OutputStream) {
t.writeTo(output)
}
}
The serializer handles protobuf encoding/decoding. The defaultValue is used when the DataStore is empty or corrupted.
Reading and Writing Typed Data
Create the DataStore
val Context.userSettingsDataStore by dataStore(
fileName = "user_settings.pb",
serializer = UserSettingsSerializer
)
Writing Data
viewModelScope.launch {
context.userSettingsDataStore.updateData { current ->
current.toBuilder()
.setUsername("amazon_dev")
.setDarkMode(true)
.setFontSize(14)
.setTheme(UserSettings.Theme.DARK)
.addFavoriteCategories("technology")
.addFavoriteCategories("science")
.build()
}
}
updateData is atomic — the lambda receives the current state and returns the new state. Multiple concurrent updates are queued and applied sequentially.
Reading as Flow
val themeFlow: Flow<UserSettings.Theme> = context.userSettingsDataStore.data
.catch { e ->
if (e is IOException) emit(UserSettings.getDefaultInstance())
else throw e
}
.map { it.theme }
// In ViewModel
val currentTheme: StateFlow<UserSettings.Theme> = themeFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UserSettings.Theme.SYSTEM
)
Reading Once
suspend fun getCurrentUsername(): String {
return context.userSettingsDataStore.data.first().username
}
Partial Updates with updateData
updateData receives the current settings and returns the modified version. Use toBuilder() to avoid creating a new object from scratch:
context.userSettingsDataStore.updateData { settings ->
settings.toBuilder()
.setFontSize(settings.fontSize + 2)
.build()
}
This preserves all other fields while changing only font size.
Schema Evolution
Adding New Fields
Protobuf handles backward compatibility. Add new fields with higher tag numbers:
message UserSettings {
string username = 1;
bool dark_mode = 2;
int32 font_size = 3;
Theme theme = 4;
repeated string favorite_categories = 5;
bool notifications_enabled = 6; // New field
}
Old data files without notifications_enabled deserialize with the default value (false). New data files with unrecognized fields ignore them gracefully.
Deprecating Fields
Never reuse a removed tag number. Mark fields as deprecated instead:
message UserSettings {
reserved 7;
string username = 1;
bool dark_mode = 2;
}
The reserved directive prevents accidental reuse of tag 7.
Migration Strategy
Proto DataStore has no built-in migration like Preferences DataStore's SharedPreferencesMigration. For schema changes:
- Additive changes (new fields) — just ship the new proto definition
- Renaming fields — use
[json_name]option to maintain compatibility - Breaking changes — create a new DataStore file and migrate data manually
suspend fun migrateToNewSchema() {
val oldStore = context.dataStore(fileName = "old_settings.pb", serializer = OldSerializer)
val newStore = context.dataStore(fileName = "new_settings.pb", serializer = NewSerializer)
val oldData = oldStore.data.first()
newStore.updateData { _ ->
NewSettings.newBuilder()
.setUsername(oldData.username)
.setDarkMode(oldData.darkMode)
.build()
}
}
When to Use Proto vs Preferences
- Proto: Structured settings, nested objects, type safety, complex enums
- Preferences: Simple flags, strings, numbers with flat structure
- Room: When you need querying, relations, or large datasets
Quiz
1. What file format defines the schema for Proto DataStore?
2. What happens when a new proto field is added but existing data doesn't contain it?
3. What is the correct way to update a single field in Proto DataStore while preserving others?
4. Why should you never reuse a removed proto tag number?
Flashcards
Question
What does a proto tag number represent?
Click to reveal answer
Answer
A numeric identifier for a field in protobuf serialization. It is used to identify fields, not field names.
Question
How does Proto DataStore handle concurrent writes?
Click to reveal answer
Answer
updateData queues writes and applies them sequentially. Each call receives the latest state and returns the modified version atomically.
Question
What is the role of Serializer in Proto DataStore?
Click to reveal answer
Answer
Defines how to read from and write to the underlying file. It handles protobuf encoding/decoding and provides a default value.
Question
How do you prevent accidental reuse of removed proto fields?
Click to reveal answer
Answer
Use the reserved directive (reserved 7;) to block tag numbers from being reused in future schema changes.
Revision Notes
Key Takeaways
- 1. Proto DataStore provides strongly typed structured data using Protocol Buffers
- 2. toBuilder() is the efficient way to update individual fields while preserving others
- 3. Adding new fields is backward compatible — missing fields use default values
- 4. Never reuse removed tag numbers — use reserved instead
Interview Tips
- • Explain the difference between Preferences DataStore (flat key-value) and Proto DataStore (typed messages)
- • Discuss protobuf backward compatibility — new fields get defaults when reading old data
- • Know how toBuilder() avoids unnecessary object creation during partial updates
- • Be ready to describe schema evolution strategies when a breaking change is unavoidable
Cheat Sheet
Proto DataStore Cheat Sheet
Proto File:
message UserSettings {
string username = 1;
bool dark_mode = 2;
}
Serializer: Implement Serializer<T> with readFrom and writeTo.
DataStore:
val Context.store by dataStore("settings.pb", SettingsSerializer)
Write:
store.updateData { it.toBuilder().setField(value).build() }
Read:
store.data.map { it.field }.catch { emit(default) }
Schema Rules:
- Add fields with higher tag numbers (backward compatible)
- Never reuse removed tag numbers
- Use
reservedfor removed tags