All about Preferences DataStore

Simona Milanović
Android Developers
6 min readJan 24, 2022

--

In this post, we will take a look at Preferences DataStore, one of two DataStore implementations. We will go over how to create it, read and write data, and how to handle exceptions, all of which should, hopefully, provide you with enough information to decide if it’s the right choice for your app.

Preferences DataStore uses key-value pairs to store smaller datasets, without defining the schema upfront. This might remind you of SharedPreferences, but only in the way it structures your data models. There are a variety of benefits brought by DataStore over its SharedPreferences predecessor. Feel free to quickly jump back to our previous post and take a look at the detailed comparison we’ve made there. Going forward, we will refer to Preferences DataStore as just Preferences, unless specified otherwise.

To quickly recap:

  • Provides a fully asynchronous API for retrieving and saving data, using the power of Kotlin coroutines
  • Does not offer ready-to-use synchronous support — it directly avoids doing any work that blocks the UI thread
  • Relies on Flow’s inner error signaling mechanism, allowing you to safely catch and handle exceptions when reading or writing data
  • Handles data updates transactionally in an atomic read-modify-write operation, providing strong ACID guarantees
  • Allows easy and quick data migrations
  • Wish to quickly migrate from SharedPreferences with minimal changes and feel confident enough without full type safety? Choose Preferences over Proto

Now let’s dive into some code and learn how Preferences should be implemented.

We will use the Preferences DataStore codelab code. If you’re interested in a more hands-on approach with implementation, we really encourage you to go through the Working with Preferences DataStore codelab on your own.

This sample app displays a list of tasks and the user can choose to filter them by their completed status or sort by priority and deadline. We want to store their selection — a boolean for completed tasks and a sort order enum in DataStore.

Dependency setup

Let’s start with adding the necessary dependency:

💡 Quick tip — if you want to minify your build, make sure to add an additional rule to your proguard-rules.pro file to prevent your fields from being deleted:

Creating a Preferences DataStore

You interact with data saved in DataStore through an instance of DataStore<Preferences>. DataStore is an interface that grants access to the persisted information. Preferences is an abstract class similar to a generic Map, used specifically in the Preferences implementation of DataStore to keep track of your data’s key-value pairs. We will talk about its MutablePreferences subclass when we discuss writing data.

To create this instance, it is recommended to use the delegate preferencesDataStore and pass a mandatory name argument. This delegate is a Kotlin extension property whose receiver type must be an instance of Context, subsequently needed for constructing the File object, where DataStore stores the data:

You shouldn’t create more than one instance of DataStore for a given file, as doing so can break all DataStore functionality. Therefore, you can add the delegate construction once at the top level of your Kotlin file and use it throughout your application, in order to pass it as a singleton. In later posts, we will see how to do this with dependency injection.

Defining keys

DataStore provides a quick way of constructing keys for different data types, such as booleanPreferencesKey, intPreferencesKey and many more — you just need to pass the key name as value. While this does put some constraints on data types, keep in mind that it doesn’t provide definite type safety. By specifying a preference key of a certain type, we hope for the best and rely on our assumptions that a value of a certain type would be returned. If you feel your code is structured in a way to handle this safely, feel free to continue with Preferences. If not, consider using Preferences’ sibling, Proto DataStore, as it provides full type safety.

In our app’s UserPreferencesRepository, we specify all the keys needed for structuring the key-value pairs of our persisted data:

Reading data

To read the stored data, in UserPreferencesRepository we expose a Flow<Preferences> from dataStore.data. This provides efficient access to the latest saved state and emits with every change. Using Kotlin data classes, we can observe any emissions and transform the provided DataStore Preferences object into our own UserPreferences model, using only the key-value pairs we’re interested in:

The Flow will always either emit a value or throw an exception when attempting to read from disk. We will look at exception handling in later sections. DataStore also ensures that work is always performed on Dispatchers.IO so your UI thread isn’t blocked.

🚨 Do not create any cache repositories to mirror the current state of your Preferences data. Doing so would invalidate DataStore’s guarantee of data consistency. If you require a single snapshot of your data without subscribing to further Flow emissions, prefer using dataStore.data.first():

Writing data

For writing data, we will use a suspend DataStore<Preferences>.edit(transform: suspend (MutablePreferences) -> Unit) function. Let’s break that down:

  • DataStore<Preferences> interface — we’re currently using datastore as the concrete Preferences implementation
  • transform: suspend (MutablePreferences) -> Unit — a suspend block used to apply the specified changes to our persisted data
  • MutablePreferences — a mutable subclass of Preferences, similar to MutableMap, that allows us to make changes to our key-value pairs

As an example, we will change our SHOW_COMPLETED flag:

Editing data is done transactionally in an atomic read-modify-write operation. This means that the specific order of data processing operations, during which the data is locked for other threads, guarantees consistency and prevents race conditions. Only after the transform and edit coroutines complete successfully, the data will be persisted durably to disk and datastore.data Flow will be reflecting the update.

🚨 Keep in mind that this is the only way of making changes to the DataStore state. Keeping a MutablePreferences reference and mutating it manually after transform completes will not change the persisted data in DataStore, so you shouldn’t attempt to modify MutablePreferences outside of the transform block.

If the writing operation fails for any reason, the transaction is aborted and an exception is thrown.

Migrate from SharedPreferences

If you’ve previously used SharedPreferences in your app and would like to safely transfer its data to Preferences, you can use SharedPreferencesMigration. It requires a context, SharedPreferences name and an optional set of keys you wish to migrate (or just leave it as the default MIGRATE_ALL_KEYS value).

Looking into SharedPreferencesMigration implementation, you will see a getMigrationFunction() which is responsible for fetching all required, stored key-value pairs and then adding them to Preferences using the same keys. Pass SharedPreferencesMigration via the produceMigrations parameter of the ​​preferencesDataStore delegate to migrate easily:

produceMigrations will ensure that the getMigrationFunction() is run before any potential data access to DataStore. This means your migration must have succeeded before DataStore emits any further values and before it begins making any new changes to the data. Once you’ve successfully migrated, it’s safe to stop using SharedPreferences, as the keys are migrated only once and then removed from SharedPreferences.

The produceMigrations accepts a list of DataMigration. We will see in later episodes how we can use this for other types of data migrations. If you don’t need to migrate, you can ignore this as it has a default listOf() provided already.

Exception handling

One of the main advantages of DataStore over SharedPreferences is its neat mechanism for catching and handling exceptions. While SharedPreferences throws parsing errors as runtime exceptions, leaving room for unexpected, uncaught crashes, DataStore throws an IOException when an error occurs with reading/writing data.

We can safely handle this by using the catch() Flow operator right before map() and emitting emptyPreferences():

Or with a simple try-catch block on writing:

If a different type of exception is thrown, prefer re-throwing it.

To be continued

We’ve covered DataStore’s Preferences implementation — when and how to use it for reading and writing data, how to handle errors and how to migrate from SharedPreferences. In the next post, we will cover the same topics with the Proto DataStore implementation, so stick around.

You can find all posts from our Jetpack DataStore series here:
Introduction to Jetpack DataStore
All about Preferences DataStore
All about Proto DataStore
DataStore and dependency injection
DataStore and Kotlin serialization
DataStore and synchronous work
DataStore and data migration
DataStore and testing

--

--

Simona Milanović
Android Developers

Android Developer Relations Engineer @Google, working on Jetpack Compose