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
| Store | Model | Use when |
|---|---|---|
shared_preferences | Key-value | A handful of flags, no queries needed |
Hive | Key-value objects | Fast local documents, no relational queries |
sqflite | SQLite SQL | Joins and indexes, you are happy writing SQL |
Drift | SQLite typed API | Relational data with compile-time safe queries |
flutter_secure_storage | Key-value, encrypted | Tokens, 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
watchstreams 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.
Related
Networking, JSON and serialization Testing Flutter apps
Last refreshed 2026-09-18.