Droid Manual // Android Knowledge Base

Droid Manual

Android Data Persistence with DataStore

October 28, 2026 · 7 min read

Introduction

Google's official migration guide explicitly recommends moving away from SharedPreferences for new projects, and there are good reasons to listen. SharedPreferences lacks type safety, throws blocking I/O exceptions on the calling thread, provides no schema evolution path, and offers no corruption recovery. Jetpack DataStore replaces all of that with a modern, coroutine-based API that writes changes to an in-memory map before persisting them atomically to disk.

This tutorial covers both flavors of DataStore—Preferences DataStore for simple key–value pairs and Proto DataStore for strongly typed, schema-versioned objects—and shows you how to wire them into an offline-first architecture that mirrors the reliability and testability of modern Android apps.

Why Migrate Away from SharedPreferences

Legacy preferences APIs were designed before coroutines and structured concurrency existed. Calling getString() or getBoolean() blocks the thread that invokes it, which means accidental usage on the main thread silently freezes the UI. There is no compile-time guarantee about which keys exist, so typos surface only as runtime nulls. Even worse, SharedPreferences offers no built-in migration path: if your app needs to rename a key or change its default, users must either clear app data manually or you must write custom one-shot migration logic inside a background thread.

DataStore eliminates every one of those pain points. It exposes preferences as a hot Flow<T>, meaning consumers can reactively subscribe and the value is always the latest committed snapshot. The write path uses edit {} with transactional semantics and never blocks the calling thread. Proto DataStore goes further by generating Kotlin data classes from .proto definitions, so key names become compile-safe fields and the schema travels with your binary.

Adding DataStore to Your Project

Before you can use either flavor, you need to add the correct Gradle dependency. Preferences DataStore lives in the androidx.datastore:preferences artifact, while Proto DataStore requires the generated protobuf plugin.

// build.gradle (app module)
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'com.google.protobuf' version '0.9.4'
}

dependencies {
    implementation("androidx.datastore:datastore-preferences:1.1.1")
    implementation("androidx.datastore:datastore:1.1.1")
    implementation("com.google.protobuf:protobuf-kotlin:4.26.1")
}

The protobuf-kotlin artifact tells the protobuf compiler to emit Kotlin data classes rather than the default Java GeneratedMessageV3 subclass hierarchy, which keeps generated files concise and idiomatic. The datastore-preferences artifact is a thin wrapper around the core datastore library, so both dependencies should coexist in the same module. Once the build script syncs, Android Studio indexes the new Gradle plugin and the protobuf code-gen task becomes available during every assemble pass.

Preferences DataStore for Key–Value Storage

Preferences DataStore is the closest drop-in replacement for SharedPreferences, but with a reactive API instead of imperative reads and writes. You typically create a singleton or use Hilt's @Provides binding so that the same underlying file is shared across the process.

// data/user/UserPreferences.kt
object UserPreferences {

    private const val USER_PREFERENCES_FILE = "user_preferences.pb"

    val preferences: DataStore<Preferences> = context.createDataStore(
        fileName = USER_PREFERENCES_FILE
    )

    private val USERNAME_KEY = stringPreferencesKey("username")
    private val THEME_KEY = booleanPreferencesKey("use_dark_theme")
    private val MAX_CACHE_AGE_DAYS_KEY = intPreferencesKey("max_cache_age_days")

    suspend fun saveUsername(username: String) {
        preferences.edit { prefs ->
            prefs[USERNAME_KEY] = username
        }
    }

    fun observeTheme(): Flow<Boolean> {
        return preferences.data
            .map { prefs -> prefs[THEME_KEY] ?: false }
            .catch { exception ->
                if (exception is IOException) emit(false)
                else throw exception
            }
    }
}

stringPreferencesKey, booleanPreferencesKey, and intPreferencesKey are type-safe wrappers that prevent accidental cross-type misuse at compile time. The edit {} block is transactional: if any assertion inside it fails, the write is rolled back atomically, which means partial corruptions never reach disk. The catch clause on the flow handles the only exceptional case the datastore throws natively—an IOException during deserialization—by supplying a fallback default so that the app stays responsive even when the preferences file is temporarily unreadable.

Proto DataStore for Schema-Validated State

Proto DataStore is the right choice when your persisted object has multiple interdependent fields or when you need a guaranteed schema across app versions. You define the shape once in a .proto file, the protobuf compiler generates a Kotlin data class, and every read/write path is type-checked.

// src/main/proto/user_profile.proto
syntax = "proto3";

option java_package = "com.example.app.data.proto";
option kotlin_package = "com.example.app.data.proto";

message UserProfile {
    string username = 1;
    bool use_dark_theme = 2;
    int32 max_cache_age_days = 3;
    int64 last_sync_epoch_ms = 4;
}

After adding the proto file and syncing Gradle, the compiler generates UserProfile inside the package you declared. You can now read and write it with full compile-time safety.

// data/profile/UserProfileStore.kt
class UserProfileStore(private val context: Context) {

    private val dataStore: DataStore<UserProfile> = context.createDataStore(
        fileName = "user_profile.pb",
        serializer = UserProfileSerializer
    )

    val profile: Flow<UserProfile> = dataStore.data
        .catch { exception ->
            if (exception is IOException) {
                emit(UserProfile.getDefaultInstance())
            } else {
                throw exception
            }
        }

    suspend fun updateProfile(block: UserProfile.() -> UserProfile) {
        dataStore.edit { current ->
            block(current)
        }
    }
}

object UserProfileSerializer : Serializer<UserProfile> {
    override val defaultValue: UserProfile = UserProfile.getDefaultInstance()

    override fun serialize(data: UserProfile) = data.toByteArray()

    override fun deserialize(bytes: ByteArray) = UserProfile.parseFrom(bytes)
}

createDataStore<UserProfile>() requires an explicit Serializer because the generic overload cannot infer one from the message type alone. The serializer translates between the protobuf byte representation and the Kotlin data class, and the defaultValue field ensures the flow never emits null even if the file is freshly created. Using edit {} with a lambda lets you mutate the message immutably—the protobuf builder semantics guarantee the original instance is left untouched while a new serialized snapshot is written to disk.

Offline-First Patterns with DataStore

An offline-first architecture treats local persistence as the source of truth and pushes changes upstream when connectivity allows. DataStore's flow-based API makes this pattern straightforward: the UI collects a Flow<UserProfile> that always reflects the latest committed state, whether that state came from a network response or from the local file.

// repository/UserRepository.kt
class UserRepository(
    private val profileStore: UserProfileStore,
    private val api: UserService
) {

    val userProfile: Flow<UserProfile> = profileStore.profile
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000L),
            initialValue = UserProfile.getDefaultInstance()
        )

    suspend fun syncFromRemote() {
        val remoteProfile = api.getProfile()
        profileStore.updateProfile {
            setUsername(remoteProfile.username)
            setUseDarkTheme(remoteProfile.useDarkTheme)
            setMaxCacheAgeDays(remoteProfile.maxCacheAgeDays)
            setLastSyncEpochMs(System.currentTimeMillis())
        }
    }
}

stateIn materializes the cold flow as a hot, replay-enabled shared flow scoped to the view model lifecycle, so configuration changes do not restart the coroutine and no network call is issued until the downstream collector unsubscribes for five seconds. The syncFromRemote() method overwrites local fields only after a successful HTTP response, which preserves stale-but-valid data if the network is unreachable. Because updateProfile uses a pure function style, tests can assert on the resulting UserProfile without needing a real disk file.

Migrating from SharedPreferences to DataStore

Migration is the most common real-world concern, especially when your app already ships with SharedPreferences populated by user behavior. The cleanest approach is to read the legacy keys once in a background coroutine, write them into DataStore, and then mark the migration as complete with a boolean flag.

// data/legacy/SharedPreferencesMigration.kt
object SharedPreferencesMigration {

    suspend fun migrate(
        context: Context,
        legacyPrefs: SharedPreferences,
        profileStore: UserProfileStore
    ): Result<Unit> = runCatching {
        val hasMigrated = legacyPrefs.getBoolean("has_migrated_to_datastore", false)
        if (!hasMigrated) {
            val username = legacyPrefs.getString("username", null) ?: ""
            val darkTheme = legacyPrefs.getBoolean("use_dark_theme", false)
            val maxAge = legacyPrefs.getInt("max_cache_age_days", 7)

            profileStore.updateProfile {
                setUsername(username)
                setUseDarkTheme(darkTheme)
                setMaxCacheAgeDays(maxAge)
                setLastSyncEpochMs(System.currentTimeMillis())
            }

            legacyPrefs.edit().putBoolean("has_migrated_to_datastore", true).apply()
        }
    }
}

runCatching wraps the migration so that any corruption in the legacy file becomes a recoverable error rather than an app crash. Calling apply() instead of commit() on the shared preferences editor ensures the flag write itself does not block the main thread. The migration runs once during the first cold start after the update, and subsequent launches skip the read because the boolean flag is already true.

Common Pitfalls

DataStore is simpler than SharedPreferences in theory, but several subtle traps catch developers off guard. First, never call DataStore.edit {} from the main thread if your transaction is long-running; although DataStore itself defers serialization, a heavy edit block that performs I/O can still stall the UI thread. Second, always handle IOException in your catch clauses—DataStore throws it when the storage file is corrupted or unreadable, and an unhandled exception collapses the entire flow. Third, do not confuse SharedPreferencesDataStore with ProtoDataStore; they are distinct types, and the serializer argument is mandatory for the proto variant. Fourth, avoid creating multiple DataStore instances for the same filename inside the same process, because each instance maintains its own in-memory cache and will read stale values until the process is killed. Finally, remember that DataStore does not support query-based lookups: every read returns the entire snapshot, so prefer it for small to medium state objects rather than as a general-purpose database replacement.

Conclusion

DataStore replaces the brittle, thread-blocking patterns of SharedPreferences with a coroutine-native, flow-driven API that integrates naturally into modern Android architectures. Preferences DataStore gives you quick migration paths for simple key–value pairs, while Proto DataStore enforces schema discipline for richer domain models. When combined with offline-first repositories and careful exception handling, DataStore becomes a reliable foundation for persistent state that scales from a single preference screen to a full offline-capable application. The learning curve is shallow, the testing story is strong, and the long-term maintenance burden drops significantly compared to legacy preference code.