Skip to main content

Local storage

Persisting data on the device: key-value settings, structured data in SQLite, secrets in the platform keystore, and plain files. Choosing wrongly here is expensive to undo, so the decision table comes first.

Contents

Choosing a mechanism

MechanismGood forNot for
shared_preferencesFlags, theme mode, last tab, small primitivesAnything you query, anything large, anything secret
sqfliteStructured, queryable, relational dataSimple settings
driftThe same, with type-safe queries and migrationsTiny apps where raw SQL is enough
flutter_secure_storageTokens, keys, credentialsBulk data — it is slow and size-limited
Files via path_providerImages, exports, caches, downloaded documentsData you need to query
hive / isar / objectboxFast object storage without SQLCases where you want portable SQL

A useful rule: if you would write a WHERE clause, use a database. If it is one value the app reads at start-up, use preferences. If leaking it would matter, use secure storage.

shared_preferences

flutter pub add shared_preferences
final prefs = await SharedPreferences.getInstance();

await prefs.setString('themeMode', 'dark');
await prefs.setBool('onboarded', true);
await prefs.setInt('launchCount', count + 1);

final theme = prefs.getString('themeMode') ?? 'system';
await prefs.remove('onboarded');

Notes:

  • It is backed by NSUserDefaults on iOS and SharedPreferences on Android. Values are not encrypted and are readable on a rooted or jailbroken device.
  • Writes are asynchronous; the value is cached in memory, so reads after the first load are cheap.
  • Newer API surfaces (SharedPreferencesAsync and a cached variant) exist in recent releases of the plugin with clearer semantics around the in-memory cache. Check the plugin's README for the version you pin and prefer them in new code.

Wrap it rather than calling it from widgets, so the storage choice stays replaceable and the keys live in one place:

class SettingsStore {
SettingsStore(this._prefs);
final SharedPreferences _prefs;

static const _themeKey = 'themeMode';

ThemeMode get themeMode => switch (_prefs.getString(_themeKey)) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};

Future<void> setThemeMode(ThemeMode mode) =>
_prefs.setString(_themeKey, mode.name);
}

SQLite with sqflite

flutter pub add sqflite path
Future<Database> openAppDatabase() async {
final path = join(await getDatabasesPath(), 'app.db');

return openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
updated_at INTEGER NOT NULL
)
''');
await db.execute('CREATE INDEX idx_products_name ON products(name)');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE products ADD COLUMN currency TEXT');
}
},
);
}

Reading and writing:

final rows = await db.query(
'products',
where: 'name LIKE ?',
whereArgs: ['%$term%'],
orderBy: 'name ASC',
limit: 50,
);
final products = rows.map(Product.fromRow).toList();

await db.insert('products', product.toRow(),
conflictAlgorithm: ConflictAlgorithm.replace);

await db.transaction((txn) async {
for (final p in products) {
await txn.insert('products', p.toRow(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
});

Always use parameter binding (? plus whereArgs) rather than string interpolation — it prevents SQL injection and lets SQLite reuse the prepared statement. Wrap bulk writes in a transaction; inserting a thousand rows without one is orders of magnitude slower.

General SQLite behaviour, types, and pragmas are covered in SQLite.

Drift

Drift builds a typed API over SQLite, with compile-time-checked queries and a managed migration path.

flutter pub add drift drift_flutter
flutter pub add --dev drift_dev build_runner
class Products extends Table {
TextColumn get id => text()();
TextColumn get name => text().withLength(min: 1, max: 200)();
RealColumn get price => real()();
DateTimeColumn get updatedAt => dateTime()();

@override
Set<Column> get primaryKey => {id};
}

@DriftDatabase(tables: [Products])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(driftDatabase(name: 'app'));

@override
int get schemaVersion => 1;

Future<List<Product>> search(String term) =>
(select(products)..where((p) => p.name.like('%$term%'))).get();

Stream<List<Product>> watchAll() => select(products).watch();
}

watch() returning a Stream is the feature that matters most: the UI updates automatically when the underlying rows change, which removes a whole category of manual refresh code. Combine it with StreamBuilder or a stream provider from State management.

Secure storage

flutter pub add flutter_secure_storage
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);

await storage.write(key: 'refresh_token', value: token);
final token = await storage.read(key: 'refresh_token');
await storage.delete(key: 'refresh_token');

It uses the iOS Keychain and the Android Keystore. Caveats worth knowing before you depend on it:

  • Keychain items survive app uninstall on iOS unless you clear them on first run. Users who reinstall to "fix" an auth problem will still be broken.
  • Android backup can capture or drop encrypted preferences depending on your backup rules; set them explicitly.
  • It is not a place for megabytes. Store the key there and encrypt the bulk data elsewhere.
  • Nothing on the device is safe from a determined attacker with physical access. Treat it as raising the cost, not as a guarantee — see Security.

Files

flutter pub add path_provider path
final docs = await getApplicationDocumentsDirectory(); // backed up, persistent
final cache = await getTemporaryDirectory(); // OS may delete freely
final support = await getApplicationSupportDirectory(); // app-internal data

final file = File(join(docs.path, 'export.json'));
await file.writeAsString(jsonEncode(payload), flush: true);

if (await file.exists()) {
final text = await file.readAsString();
}

Choose the directory by intent: user documents in documents, regenerable data in cache, internal data in support. Putting a cache in the documents directory inflates the user's backup and can get an app rejected from the App Store.

Never hard-code a path — the sandbox path changes between installs and OS versions. Always resolve it at runtime.

Migrations

Schema changes are the part that breaks in production, because you cannot test against a device that already holds old data unless you deliberately do so.

  • Bump version and handle every oldVersion range in onUpgrade.
  • With Drift, generate schema snapshots and write migration tests against them.
  • For preferences, treat missing keys as the default rather than migrating; for renamed keys, read the old key once and write the new one.
  • Test the upgrade path: install the previous release, create data, then install the new build over it. A fresh install proves nothing about migration.

Security notes

DataWhere
Access and refresh tokensSecure storage
API keys for third-party servicesNot in the app at all — proxy through your backend
Personal data cached offlineEncrypted database, or a database in an encrypted container
Feature flags, UI statePreferences

Disable or scope platform backups for sensitive data (android:allowBackup and data-extraction rules on Android; exclude paths from iCloud backup on iOS).

Testing

Every mechanism has an in-memory or fake variant, so storage code is testable:

// shared_preferences
SharedPreferences.setMockInitialValues({'themeMode': 'dark'});

// sqflite: use sqflite_common_ffi with an in-memory database in tests
// drift: NativeDatabase.memory()
final db = AppDatabase.forTesting(NativeDatabase.memory());

Write tests for the repository, not for the plugin. What you care about is that "save then reload returns the same object" and that migrations preserve data. See Testing.

Next: State management.