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
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
- SharedPreferences problems — Synchronous reads can block the main thread. No type safety — getInt() on a String key returns the default silently. No Flow support natively. commit() is synchronous, apply() is asynchronous but can lose data on process kill. No transactions — concurrent writes can corrupt. Officially in maintenance mode — no new features.
- Preferences DataStore — Jetpack DataStore backed by Kotlin coroutines and Flow. Type-safe key declarations with Preferences.Key (stringPreferencesKey, intPreferencesKey, etc.). Fully asynchronous — no blocking reads. Atomic writes via transactions. Survives process kills without data loss. Single-file key-value store, same use cases as SharedPreferences but correctly implemented.
- Proto DataStore — Uses Protocol Buffers (protobuf) for strongly typed storage. Define your schema in a .proto file — the compiler generates Kotlin data classes. No stringly-typed keys — access via generated properties. Smaller file size than JSON, binary format. Best for complex structured preferences with nested objects. Requires protobuf dependency and .proto schema setup.
- When to use Preferences DataStore — Simple key-value pairs: theme preference, language selection, first-launch flag, feature flags, last sync timestamp, notification settings. Replaces all SharedPreferences use cases. Not for: large datasets, relational data, frequently queried/filtered data, data that needs to be shared across processes (use ContentProvider + Room instead).
- When to use Proto DataStore — Structured user preferences that map naturally to a typed schema: UserSettings with nested fields (display options, notification prefs, privacy settings). Team enforces a contract for settings structure. Binary protobuf is more efficient than SharedPreferences' XML. Worth the setup cost when settings have 10+ fields with nested structure.
- When to use Room — Any data that is: a list (more than one item), queried/filtered, relational (joins), paginated, updated independently per record, needed offline with sync. Examples: cached API responses, user-generated content, transaction history, attendance records, chat messages. Room is not overkill for medium datasets — it's the right tool.
- Reading from DataStore — dataStore.data returns Flow — map to extract typed values. val themeFlow = dataStore.data.map { prefs -> prefs[THEME_KEY] ?: 'system' }. Collect in ViewModel with stateIn(). Never call dataStore.data.first() on the main thread — use withContext(IO) or let Flow handle it.
- Writing to DataStore — dataStore.edit { prefs -> prefs[THEME_KEY] = 'dark' } — edit is a suspend function wrapping an atomic transaction. If you need to update multiple keys atomically, do it inside a single edit {} block — all changes commit together or not at all.
- Migrating from SharedPreferences to DataStore — Use SharedPreferencesMigration — pass the SharedPreferences name to DataStore builder. DataStore reads existing SharedPreferences data on first access and migrates it automatically. After successful migration, the SharedPreferences file is deleted. Migration runs exactly once.
- Performance comparison — SharedPreferences: synchronous reads (can block main thread ~1-5ms), XML parsing on init. DataStore: async IO only, Dispatchers.IO managed internally, no main thread risk. Room: SQLite queries on IO thread, efficient with indices. For 10 preference keys, Preferences DataStore and SharedPreferences are comparable in speed; DataStore wins on correctness and safety.
- Multi-process caution — Neither SharedPreferences nor DataStore is safe for multi-process access (e.g., app + widget + service in separate processes). For multi-process data sharing, use a ContentProvider backed by Room. DataStore does support a multi-process variant (MultiProcessDataStore) added in 1.1.0 but requires explicit enablement.
- DataStore singleton pattern — Instantiate DataStore once per process — use a top-level property delegate or inject via Hilt @Singleton. Creating multiple DataStore instances pointing to the same file causes data corruption. The by preferencesDataStore(name) property delegate on Context is the canonical approach and handles singleton enforcement automatically.
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. 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. PreferencesKeys groups all key declarations — stringPreferencesKey, booleanPreferencesKey etc. create typed keys; using the wrong type causes a ClassCastException at runtime.
- 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. 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. 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. 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. UserSettingsSerializer implements the Serializer interface — readFrom parses the protobuf binary stream, writeTo serialises back; the defaultValue is used when no file exists yet.
- 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. The migration lambda runs once on first DataStore access — after successful migration, DataStore deletes the SharedPreferences file automatically.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- DataStore — Android Developers Guide (Android Developers)
- Migrate from SharedPreferences to DataStore (Android Developers)
- Proto DataStore — Protocol Buffers setup (Android Developers)
- SharedPreferences vs DataStore — The full migration guide (Android Developers Medium)
- When to use Room vs DataStore — Decision guide (ProAndroidDev)