SharedPreferences has been the go-to way to store simple data on Android for over a decade. It works. But it has problems.
It runs on the main thread and can cause jank. It has no type safety — you request a string and get a crash if the key holds an integer. It has no way to observe changes reactively. And it does not handle errors — a corrupted file means lost data with no recovery.
DataStore fixes all of these. It is async (uses coroutines), type-safe, based on Flow, and handles errors gracefully. It is the official replacement for SharedPreferences.
Prerequisites: Kotlin Tutorial: Flow and Compose Tutorial #5: State.
Preferences DataStore vs Proto DataStore
DataStore comes in two flavors:
| Feature | Preferences DataStore | Proto DataStore |
|---|---|---|
| Schema | Key-value pairs | Structured (typed) |
| Type safety | Partial (keys are typed) | Full |
| Setup | Simple | Requires schema definition |
| Best for | Settings, flags, simple values | Complex structured data |
| Migration from SharedPrefs | Direct | Requires mapping |
Start with Preferences DataStore. It covers 90% of use cases. Use Proto DataStore only when you need complex nested objects.
Setup
# gradle/libs.versions.toml
[versions]
datastore = "1.2.1"
[libraries]
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
// build.gradle.kts
dependencies {
implementation(libs.datastore.preferences)
}
Preferences DataStore
Creating a DataStore
// At the top level of a file (not inside a class)
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings"
)
This creates a file at data/data/your.package/files/datastore/settings.preferences_pb.
Defining Keys
object SettingsKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val LANGUAGE = stringPreferencesKey("language")
val FONT_SIZE = intPreferencesKey("font_size")
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val USERNAME = stringPreferencesKey("username")
}
Available key types: booleanPreferencesKey, intPreferencesKey, longPreferencesKey, floatPreferencesKey, doublePreferencesKey, stringPreferencesKey, stringSetPreferencesKey.
Reading Data
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.settingsDataStore
val isDarkMode: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SettingsKeys.DARK_MODE] ?: false
}
val language: Flow<String> = dataStore.data.map { preferences ->
preferences[SettingsKeys.LANGUAGE] ?: "en"
}
val fontSize: Flow<Int> = dataStore.data.map { preferences ->
preferences[SettingsKeys.FONT_SIZE] ?: 16
}
val notificationsEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true
}
}
DataStore returns a Flow. When the value changes, the Flow emits the new value automatically.
Writing Data
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.settingsDataStore
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[SettingsKeys.DARK_MODE] = enabled
}
}
suspend fun setLanguage(language: String) {
dataStore.edit { preferences ->
preferences[SettingsKeys.LANGUAGE] = language
}
}
suspend fun setFontSize(size: Int) {
dataStore.edit { preferences ->
preferences[SettingsKeys.FONT_SIZE] = size
}
}
suspend fun clearAll() {
dataStore.edit { it.clear() }
}
}
The edit function is a suspend function. It runs on a background thread automatically. You never block the main thread.
Reading All Settings at Once
Instead of observing each key separately, combine them into a data class:
data class UserSettings(
val isDarkMode: Boolean = false,
val language: String = "en",
val fontSize: Int = 16,
val notificationsEnabled: Boolean = true
)
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.settingsDataStore
val settings: Flow<UserSettings> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
UserSettings(
isDarkMode = preferences[SettingsKeys.DARK_MODE] ?: false,
language = preferences[SettingsKeys.LANGUAGE] ?: "en",
fontSize = preferences[SettingsKeys.FONT_SIZE] ?: 16,
notificationsEnabled = preferences[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true
)
}
}
The catch block handles corrupted files gracefully by falling back to empty preferences.
DataStore with Hilt
Inject DataStore properly with Hilt:
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideSettingsDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> {
return context.settingsDataStore
}
}
Now your repository can receive DataStore through constructor injection:
class SettingsRepository @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
val settings: Flow<UserSettings> = dataStore.data.map { preferences ->
UserSettings(
isDarkMode = preferences[SettingsKeys.DARK_MODE] ?: false,
language = preferences[SettingsKeys.LANGUAGE] ?: "en",
notificationsEnabled = preferences[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true
)
}
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { it[SettingsKeys.DARK_MODE] = enabled }
}
suspend fun setLanguage(language: String) {
dataStore.edit { it[SettingsKeys.LANGUAGE] = language }
}
suspend fun setNotifications(enabled: Boolean) {
dataStore.edit { it[SettingsKeys.NOTIFICATIONS_ENABLED] = enabled }
}
}
Settings Screen with Compose
A complete settings screen using DataStore:
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: SettingsRepository
) : ViewModel() {
val settings = repository.settings
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UserSettings()
)
fun toggleDarkMode() {
viewModelScope.launch {
val current = settings.value.isDarkMode
repository.setDarkMode(!current)
}
}
fun setLanguage(language: String) {
viewModelScope.launch {
repository.setLanguage(language)
}
}
fun toggleNotifications() {
viewModelScope.launch {
val current = settings.value.notificationsEnabled
repository.setNotifications(!current)
}
}
}
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
item {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 24.dp)
)
}
// Dark Mode Toggle
item {
SettingSwitch(
title = "Dark Mode",
description = "Use dark theme",
checked = settings.isDarkMode,
onCheckedChange = { viewModel.toggleDarkMode() }
)
}
// Notifications Toggle
item {
SettingSwitch(
title = "Notifications",
description = "Receive push notifications",
checked = settings.notificationsEnabled,
onCheckedChange = { viewModel.toggleNotifications() }
)
}
// Language Selector
item {
SettingDropdown(
title = "Language",
currentValue = settings.language,
options = listOf("en" to "English", "de" to "Deutsch", "tr" to "Turkish"),
onValueChange = { viewModel.setLanguage(it) }
)
}
}
}
@Composable
fun SettingSwitch(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
Proto DataStore
For structured data beyond key-value pairs, Proto DataStore provides full type safety.
Using Kotlin Serialization (Simpler Setup)
Instead of .proto files, you can use Kotlin Serialization with Proto DataStore:
@Serializable
data class AppSettings(
val theme: Theme = Theme.SYSTEM,
val fontSize: Int = 16,
val notifications: NotificationSettings = NotificationSettings(),
val lastSyncTimestamp: Long = 0L
)
@Serializable
data class NotificationSettings(
val enabled: Boolean = true,
val sound: Boolean = true,
val vibration: Boolean = true
)
@Serializable
enum class Theme { LIGHT, DARK, SYSTEM }
DataStore Serializer
object AppSettingsSerializer : Serializer<AppSettings> {
override val defaultValue: AppSettings = AppSettings()
override suspend fun readFrom(input: InputStream): AppSettings {
return try {
Json.decodeFromString(
AppSettings.serializer(),
input.readBytes().decodeToString()
)
} catch (e: SerializationException) {
defaultValue
}
}
override suspend fun writeTo(t: AppSettings, output: OutputStream) {
output.write(
Json.encodeToString(AppSettings.serializer(), t).encodeToByteArray()
)
}
}
Creating Proto DataStore
val Context.appSettingsDataStore: DataStore<AppSettings> by dataStore(
fileName = "app_settings.json",
serializer = AppSettingsSerializer
)
Reading and Writing
class AppSettingsRepository @Inject constructor(
private val dataStore: DataStore<AppSettings>
) {
val settings: Flow<AppSettings> = dataStore.data
suspend fun setTheme(theme: Theme) {
dataStore.updateData { current ->
current.copy(theme = theme)
}
}
suspend fun toggleNotificationSound() {
dataStore.updateData { current ->
current.copy(
notifications = current.notifications.copy(
sound = !current.notifications.sound
)
)
}
}
suspend fun updateLastSync() {
dataStore.updateData { current ->
current.copy(lastSyncTimestamp = System.currentTimeMillis())
}
}
}
Proto DataStore is type-safe end-to-end. No string keys. No wrong types. The compiler catches all errors.
Migration from SharedPreferences
If your app already uses SharedPreferences, DataStore can migrate automatically:
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "old_shared_prefs_name"))
}
)
DataStore reads all values from the old SharedPreferences file, writes them to DataStore, and then deletes the old file. This happens once, automatically.
Custom Migration Mapping
If key names changed:
val migration = SharedPreferencesMigration(
context = context,
sharedPreferencesName = "old_prefs"
) { sharedPrefs, currentData ->
val mutablePrefs = currentData.toMutablePreferences()
// Map old keys to new keys
if (SettingsKeys.DARK_MODE !in currentData) {
val oldValue = sharedPrefs.getBoolean("night_mode", false)
mutablePrefs[SettingsKeys.DARK_MODE] = oldValue
}
if (SettingsKeys.FONT_SIZE !in currentData) {
val oldValue = sharedPrefs.getInt("text_size", 16)
mutablePrefs[SettingsKeys.FONT_SIZE] = oldValue
}
mutablePrefs.toPreferences()
}
Multi-Process Support
If your app has multiple processes (like a foreground service in a separate process), DataStore 1.1+ supports multi-process access:
val Context.settingsDataStore by preferencesDataStore(
name = "settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
)
Multiple processes can read and write safely. DataStore handles synchronization internally.
Testing DataStore
Test with a temporary file:
class SettingsRepositoryTest {
private lateinit var dataStore: DataStore<Preferences>
private lateinit var repository: SettingsRepository
@get:Rule
val tempFolder = TemporaryFolder()
@Before
fun setup() {
dataStore = PreferenceDataStoreFactory.create(
scope = TestScope(UnconfinedTestDispatcher()),
produceFile = { tempFolder.newFile("test_settings.preferences_pb") }
)
repository = SettingsRepository(dataStore)
}
@Test
fun `default dark mode is false`() = runTest {
val settings = repository.settings.first()
assertFalse(settings.isDarkMode)
}
@Test
fun `setDarkMode updates value`() = runTest {
repository.setDarkMode(true)
val settings = repository.settings.first()
assertTrue(settings.isDarkMode)
}
@Test
fun `clearAll resets to defaults`() = runTest {
repository.setDarkMode(true)
repository.setLanguage("de")
repository.clearAll()
val settings = repository.settings.first()
assertFalse(settings.isDarkMode)
assertEquals("en", settings.language)
}
}
DataStore vs Room — When to Use Which
| Use Case | DataStore | Room |
|---|---|---|
| User preferences | Yes | No |
| Theme, language, flags | Yes | No |
| Auth tokens | Yes | No |
| Structured lists | No | Yes |
| Searchable data | No | Yes |
| Relations between objects | No | Yes |
| Large amounts of data | No | Yes |
Rule of thumb: if you would use a SQL query to access it, use Room. If you would use a key to access it, use DataStore.
What’s Next?
In this tutorial, you learned:
- Why DataStore replaces SharedPreferences
- Preferences DataStore for key-value storage
- Proto DataStore for structured typed data
- DataStore with Hilt dependency injection
- Building a settings screen with Compose
- Migrating from SharedPreferences
- Testing DataStore
Next up: Android Tutorial #9: WorkManager — background tasks that survive app restarts.
Related Articles
- Kotlin Tutorial: Flow — reactive streams
- Compose Tutorial #5: State — state management
- Android Tutorial #2: Hilt — dependency injection
- Android Tutorial #7: Room — database storage
This is part 8 of the Android Development Tutorial series.