Skip to content
advanced Phase 7 · Data & Persistence

Proto DataStore

Store type-safe structured data with Proto DataStore using Protocol Buffers.

40m
2 problems
Topic Progress 0%

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:

  1. Additive changes (new fields) — just ship the new proto definition
  2. Renaming fields — use [json_name] option to maintain compatibility
  3. 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?

Question 1 options

2. What happens when a new proto field is added but existing data doesn't contain it?

Question 2 options

3. What is the correct way to update a single field in Proto DataStore while preserving others?

Question 3 options

4. Why should you never reuse a removed proto tag number?

Question 4 options

Flashcards

Question

What does a proto tag number represent?

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?

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?

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?

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 reserved for removed tags