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:

FeaturePreferences DataStoreProto DataStore
SchemaKey-value pairsStructured (typed)
Type safetyPartial (keys are typed)Full
SetupSimpleRequires schema definition
Best forSettings, flags, simple valuesComplex structured data
Migration from SharedPrefsDirectRequires 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 CaseDataStoreRoom
User preferencesYesNo
Theme, language, flagsYesNo
Auth tokensYesNo
Structured listsNoYes
Searchable dataNoYes
Relations between objectsNoYes
Large amounts of dataNoYes

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.



This is part 8 of the Android Development Tutorial series.