Local persistence: shared_preferences, sqflite, Drift and Hive

Choose between key-value and relational storage, open a database, write migrations, and keep credentials in secure storage rather than a preferences file.

Choosing a store

StoreModelUse when
shared_preferencesKey-valueA handful of flags, no queries needed
HiveKey-value objectsFast local documents, no relational queries
sqfliteSQLite SQLJoins and indexes, you are happy writing SQL
DriftSQLite typed APIRelational data with compile-time safe queries
flutter_secure_storageKey-value, encryptedTokens, keys, anything sensitive
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('onboarding_done', true);
await prefs.setString('last_sync', DateTime.now().toIso8601String());

const storage = FlutterSecureStorage(
  aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: 'access_token', value: token);

Note the difference between SharedPreferencesAsync and the legacy SharedPreferences.getInstance() cache: the async API reads through to the platform store each time, so it is not stale when another isolate writes.

A typed database with Drift

// tables.dart
class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 200)();
  BoolColumn get done => boolean().withDefault(const Constant(false))();
  DateTimeColumn get due => dateTime().nullable()();
}

// database.dart
@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase(super.e);

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
        onCreate: (m) => m.createAll(),
        onUpgrade: (m, from, to) async {
          if (from < 2) {
            await m.addColumn(tasks, tasks.due);
          }
        },
      );

  Stream<List<Task>> watchOpen() =>
      (select(tasks)..where((t) => t.done.equals(false))).watch();
}
  • Never edit a shipped migration. Add a new schema version and a new upgrade step; users on old versions will replay them in order.
  • Use watch streams so the UI updates when any write changes the query result.
  • Run heavy writes inside a transaction: transaction(() async { ... }) commits once and is far faster than thousands of individual inserts.
  • Test migrations with a real old-version database file, not a freshly created one.

What goes wrong in production

// Cache the database handle; opening per query is expensive
AppDatabase? _db;
AppDatabase get db => _db ??= AppDatabase(
      LazyDatabase(() async {
        final dir = await getApplicationDocumentsDirectory();
        final file = File(p.join(dir.path, 'app.sqlite'));
        return NativeDatabase.createInBackground(file);
      }),
    );
⚠️
On iOS the documents directory is included in iCloud backup, while the caches directory may be deleted by the system at any time. A database belongs in documents; regenerable image caches belong in caches.

FAQ

Is <code>shared_preferences</code> safe for a token?
No. It is a plain unencrypted file in the app sandbox and is readable from a device backup. Use flutter_secure_storage, which maps to the Keychain on iOS and encrypted preferences on Android.
How much data can Hive hold?
It is a key-value store that loads boxes into memory, so it suits thousands of small objects rather than a large table with queries. Once you need filtered, sorted, joined reads, move to SQLite.

Networking, JSON and serialization Testing Flutter apps

Last refreshed 2026-09-18.