Lesson 29 of 83 intermediate

DataStore vs SharedPreferences vs Room — When to Choose

Pick the right storage primitive for every use case — preferences, proto, or relational

Open interactive version (quiz + challenge)

Real-world analogy

SharedPreferences is a Post-it note on your fridge — quick, easy, but chaotic when you have too many. Preferences DataStore is a well-organised sticky note board with labeled sections and a lock so two people can't write at the same time. Proto DataStore is a typed filing form — you define exactly what fields exist and their types, no surprises. Room is a full filing cabinet system — the right tool when Post-it notes and forms aren't enough.

What is it?

Android provides three key-value/structured storage primitives: SharedPreferences (synchronous XML, legacy), Preferences DataStore (async Flow-based key-value, SharedPrefs replacement), and Proto DataStore (typed protobuf schema, structured preferences). Room handles relational data. Choosing correctly means matching the data's shape, size, and access pattern to the right tool — key-value, typed schema, or relational.

Real-world relevance

In Hazira Khata, the teacher's selected school (a single String ID) and theme preference are stored in Preferences DataStore — simple key-value, async, replaces SharedPreferences with zero risk of main thread blocking on older devices. The actual attendance records are in Room — they're a list, queried by date and class, and synced from Firebase. User profile preferences with 15+ nested notification settings use Proto DataStore with a generated UserSettings proto class for type safety.

Key points

Code example

// 1. Preferences DataStore — setup and usage
// Single instance via property delegate (enforces singleton)
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_settings")

// Key declarations
object PreferencesKeys {
    val THEME = stringPreferencesKey("theme")
    val SELECTED_SCHOOL_ID = stringPreferencesKey("selected_school_id")
    val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
    val LAST_SYNC_TIMESTAMP = longPreferencesKey("last_sync_ts")
}

// Repository wrapping DataStore
class UserPreferencesRepository @Inject constructor(
    private val dataStore: DataStore<Preferences>
) {
    // Reading — returns Flow for reactive UI
    val theme: Flow<String> = dataStore.data
        .catch { e ->
            if (e is IOException) emit(emptyPreferences()) else throw e
        }
        .map { prefs -> prefs[PreferencesKeys.THEME] ?: "system" }

    val selectedSchoolId: Flow<String?> = dataStore.data
        .map { prefs -> prefs[PreferencesKeys.SELECTED_SCHOOL_ID] }

    // Writing — suspend function, atomic
    suspend fun setTheme(theme: String) {
        dataStore.edit { prefs ->
            prefs[PreferencesKeys.THEME] = theme
        }
    }

    // Atomic multi-key update
    suspend fun updateSchoolAndSync(schoolId: String, syncTimestamp: Long) {
        dataStore.edit { prefs ->
            prefs[PreferencesKeys.SELECTED_SCHOOL_ID] = schoolId
            prefs[PreferencesKeys.LAST_SYNC_TIMESTAMP] = syncTimestamp
            // Both keys commit together atomically
        }
    }
}

// 2. Proto DataStore — .proto schema
// File: user_settings.proto
// syntax = "proto3";
// option java_package = "com.hazirakhata.proto";
// message UserSettings {
//   string theme = 1;
//   bool notifications_enabled = 2;
//   NotificationConfig notification_config = 3;
// }
// message NotificationConfig {
//   bool daily_reminder = 1;
//   int32 reminder_hour = 2;
// }

// Serializer for Proto DataStore
object UserSettingsSerializer : Serializer<UserSettings> {
    override val defaultValue: UserSettings = UserSettings.getDefaultInstance()
    override suspend fun readFrom(input: InputStream): UserSettings =
        UserSettings.parseFrom(input)
    override suspend fun writeTo(t: UserSettings, output: OutputStream) =
        t.writeTo(output)
}

// Proto DataStore usage
val Context.userSettingsDataStore by dataStore(
    fileName = "user_settings.pb",
    serializer = UserSettingsSerializer
)

// Reading with type safety — no string keys!
val notificationHour: Flow<Int> = context.userSettingsDataStore.data
    .map { settings -> settings.notificationConfig.reminderHour }

// Writing
suspend fun setReminderHour(hour: Int) {
    context.userSettingsDataStore.updateData { current ->
        current.toBuilder()
            .setNotificationConfig(
                current.notificationConfig.toBuilder().setReminderHour(hour).build()
            )
            .build()
    }
}

// 3. Migration from SharedPreferences to DataStore
val MIGRATION = SharedPreferencesMigration(context, "legacy_prefs") { prefs, currentData ->
    // Map old SharedPreferences keys to DataStore keys
    if (prefs.contains("theme_key")) {
        currentData.toMutablePreferences().apply {
            set(PreferencesKeys.THEME, prefs.getString("theme_key", "system") ?: "system")
        }.toPreferences()
    } else {
        currentData
    }
}

// DataStore builder with migration
val dataStore = PreferenceDataStoreFactory.create(
    migrations = listOf(MIGRATION),
    produceFile = { context.preferencesDataStoreFile("user_settings") }
)

// 4. Hilt module
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
    @Provides
    @Singleton
    fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
        context.dataStore
}

Line-by-line walkthrough

  1. 1. by preferencesDataStore(name) is a Kotlin property delegate on Context — it ensures only one DataStore instance is created per Context, preventing the multi-instance corruption bug.
  2. 2. PreferencesKeys groups all key declarations — stringPreferencesKey, booleanPreferencesKey etc. create typed keys; using the wrong type causes a ClassCastException at runtime.
  3. 3. dataStore.data.catch { e -> if (e is IOException) emit(emptyPreferences()) } — this pattern gracefully handles file read errors by emitting empty preferences instead of crashing the app.
  4. 4. map { prefs -> prefs[THEME_KEY] ?: 'system' } transforms the raw Preferences into a typed value — the ?: provides the default when the key has never been written.
  5. 5. dataStore.edit { prefs -> ... } is a suspend function — it writes atomically to a temporary file then renames it, ensuring the write is never half-complete.
  6. 6. Updating SELECTED_SCHOOL_ID and LAST_SYNC_TIMESTAMP in the same edit {} block ensures both values are always in sync — no state where one is new and the other is stale.
  7. 7. UserSettingsSerializer implements the Serializer interface — readFrom parses the protobuf binary stream, writeTo serialises back; the defaultValue is used when no file exists yet.
  8. 8. SharedPreferencesMigration receives the old prefs and current DataStore data — you manually map old string keys to new typed DataStore keys in the lambda.
  9. 9. The migration lambda runs once on first DataStore access — after successful migration, DataStore deletes the SharedPreferences file automatically.
  10. 10. Hilt provides DataStore as @Singleton — matching the singleton contract enforced by the by preferencesDataStore delegate, ensuring consistent single-instance access throughout the app.

Spot the bug

// Find 3 bugs in this DataStore usage
// UserPreferencesRepo.kt
class UserPreferencesRepo(private val context: Context) {

    // Bug 1
    private fun getDataStore() = PreferenceDataStoreFactory.create(
        produceFile = { context.preferencesDataStoreFile("user_prefs") }
    )

    val theme: Flow<String> = getDataStore().data
        .map { prefs -> prefs[stringPreferencesKey("theme")] ?: "system" }

    suspend fun setTheme(newTheme: String) {
        getDataStore().edit { prefs ->    // Bug 2
            prefs[stringPreferencesKey("theme")] = newTheme
        }
    }

    fun getThemeSync(): String {         // Bug 3
        return runBlocking {
            getDataStore().data.first()[stringPreferencesKey("theme")] ?: "system"
        }
    }
}
Need a hint?
Look at DataStore instantiation, key object equality, and blocking reads.
Show answer
Bug 1: getDataStore() creates a NEW DataStore instance every time it is called — this is the critical multi-instance bug. Each instance has its own in-memory cache, so reads from one instance do not see writes from another. The theme Flow and setTheme() are operating on different instances and will never be consistent. Fix: create DataStore once as a property (val dataStore = ...) or use the by preferencesDataStore delegate on Context. Bug 2: Follows from Bug 1 — setTheme() creates yet another DataStore instance via getDataStore(). The edit to this third instance is written to disk, but the theme Flow (from a second instance) will never emit the new value because it's watching a different in-memory cache. Fix: use a single shared DataStore reference. Bug 3: getThemeSync() uses runBlocking to block the current thread — if called on the main thread (common for 'just get the current theme' use cases), it blocks the UI thread during disk IO, causing potential ANR. DataStore was designed to eliminate this pattern. Fix: expose theme as a Flow and collect it in the ViewModel with stateIn(), or use a suspend function if a one-shot read is truly needed, and always call it from a background dispatcher.

Explain like I'm 5

Imagine storing things at home. SharedPreferences is hiding stuff under your mattress — quick but messy and dangerous if two people try at the same time. Preferences DataStore is a tidy bedside drawer with labeled compartments — same stuff, but organised and safe. Proto DataStore is a typed organiser where each slot only fits exactly one thing (your phone charger, your watch, your keys) — no accidentally putting keys in the phone slot. Room is the whole wardrobe — right tool when your bedside drawer doesn't cut it.

Fun fact

SharedPreferences was introduced in Android 1.0 (2008) and has barely changed since. The data corruption bugs from concurrent writes were documented by Google engineers in 2019, leading to DataStore's creation. Despite DataStore's release in 2020, SharedPreferences still exists in millions of apps because migrations are risky — a reminder that in Android, nothing ever truly disappears.

Hands-on challenge

Audit an existing Android app's SharedPreferences usage. Categorise each use: (1) simple key-value preference → migrate to Preferences DataStore, (2) structured nested settings → migrate to Proto DataStore, (3) lists or relational data incorrectly stored as JSON strings → migrate to Room. Write the SharedPreferencesMigration for category 1, and the .proto schema definition for category 2.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery