Pick by the shape of the data: key-value for settings, a real database for records, the platform keystore for secrets.
**SharedPreferences**: a few small key-value pairs: theme, flags, onboarding done. New code uses SharedPreferencesAsync; getInstance() is legacy.
sqflite (SQLite): a relational database with SQL, transactions and migrations; for data you query, join and count. Add drift on top for typed queries.
Hive: NoSQL key-value in boxes, pure Dart, very fast at reading a whole object; take the maintained fork hive_ce.
**flutter_secure_storage**: encrypted by the platform: Keychain on iOS, Keystore on Android. The only place for tokens and passwords.
FlutterMiddle
Local Storage
SharedPreferences, sqflite, Hive, flutter_secure_storage
Questions closed
0 / 25
- 0Review
- 2Asked often
Questions
Open the file once with openDatabase (the path from getDatabasesPath()), create the tables in onCreate, then use insert, query, update and delete; values go in a map or as ? placeholders in where, never through string interpolation.
One instance: open the database once and share it through a repository or DI; every call returns a Future
Placeholders: where: 'id = ?', whereArgs: [id], never string interpolation: sqflite binds the value, so quotes cannot break the SQL
Return values: insert returns the new row id, update and delete the number of rows changed, query a List<Map<String, Object?>> you map into models
SharedPreferences is a small, untyped, non-reactive and unencrypted key-value file: load it before the UI needs it, keep only small settings in it, and never put secrets there.
The first load is async: read it in main() before runApp, or show a default while it loads
It is not reactive: nothing notifies listeners after a set; wrap it in a ValueNotifier or your state manager
Keys are untyped: a key read with the wrong getter throws a type error at runtime, never at compile time; keep every key and its type in one constants class
No transactions: the app can be killed between two set calls; save values that belong together as one JSON string
Not for secrets or records: it is a plain XML or plist file, so tokens go to flutter_secure_storage and lists of records to a database
path_provider returns the platform's directories for your app; pick one by asking whether the file is user content, app data, or something you can recreate.
**getApplicationDocumentsDirectory(): content the user created and must not lose (notes, exports); it is backed up
getApplicationSupportDirectory(): app data the user never sees: databases, downloaded models, config; backed up and never purged
getApplicationCacheDirectory() / getTemporaryDirectory(): anything you can download or compute again; the OS may delete it under storage pressure and backups skip it
Common mistake**: thumbnails in Documents: they bloat the user's iCloud backup and nothing ever cleans them
sqflite binds only num, String, Uint8List and null, because SQLite stores just numbers, text and bytes; convert everything else in the model's toMap/fromMap (or a drift TypeConverter), never at call sites.
**DateTime**: fails with Invalid argument: Instance of 'DateTime'; save millisecondsSinceEpoch (a timezone-free integer) and read it back with DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); if you prefer text, use toUtc().toIso8601String(), because a local ISO string has no offset and sorts wrongly
**bool**: not a supported type; some platforms coerce it and others reject it, so always bind 1 and 0, in whereArgs too
Enums: save value.name, not index: a case inserted in the middle would silently change the meaning of every stored row
They differ in where the truth lives: legacy SharedPreferences caches every key in Dart, SharedPreferencesAsync asks the platform on every call, and SharedPreferencesWithCache caches only the keys you allow.
**Legacy getInstance()**: loads every key into a Dart map once; reads are synchronous, but a write from another isolate or from native code stays invisible until reload(). The package plans to deprecate it
**SharedPreferencesAsync**: no Dart cache, every get is an await; always fresh, so it is the safe choice when background isolates write too
**SharedPreferencesWithCache**: created once with create(cacheOptions: ...), then synchronous reads from a cache limited by allowList; writes update the cache and the platform
Android backend: the two new APIs use Jetpack DataStore by default, the legacy one the old XML file, so the new APIs do not see values the legacy API wrote
Migrating: run migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary once at startup; it copies the old keys and stores a completion flag
Every store is just a file inside the app sandbox, so pull the file onto your machine and open it with a desktop tool.
Android: /data/data/<applicationId>/: databases/ for sqflite, shared_prefs/ for the legacy preferences API, files/datastore/ for SharedPreferencesAsync, app_flutter/ for the documents directory (where drift usually puts its file)
iOS: the app container; on the simulator xcrun simctl get_app_container booted <bundle id> data prints the path; on a real iPhone use Xcode, Window, Devices and Simulators, select the app, Download Container
Pull it: adb exec-out run-as <applicationId> cat databases/app.db > app.db, debuggable builds only
**Take the -wal file too: a WAL database copied alone is missing the newest writes, which looks exactly like a save bug
Open it**: DB Browser for SQLite, or drift's DevTools extension while the app runs
Bump the version you pass to openDatabase and bring old files forward in onUpgrade with one if (from < N) step per version; onCreate must build the latest schema directly.
onCreate runs once on an empty database, onUpgrade when your version is higher than the one on disk, and onDowngrade in the opposite case. Without an onDowngrade sqflite does not fail: it opens the file and stamps the lower version. Pass onDatabaseVersionChangeError to fail loudly or onDatabaseDowngradeDelete to wipe and recreate.
Migrate by version number: step from the old version to the new one with if (from < N), never "add the column if it is missing"
The callbacks run in a transaction: a step that throws rolls the whole upgrade back and the version stays old; schema work done after the open is not protected
**ALTER TABLE is limited**: DROP COLUMN arrived only in SQLite 3.35 and older Android versions ship an older SQLite, so changing a column usually means create a new table, copy, drop the old one, rename
Test every path: in CI, open a seeded database of each old version and upgrade it to the latest, and also create a fresh one
On a fresh install sqflite calls only onCreate, never onUpgrade, and then stamps the file as version 2. This onCreate still builds the version 1 table, so the due_at column is never added.
Why updates work: an existing file is at version 1, so onUpgrade runs and adds the column
The rule: onCreate must produce the latest schema: either the full current CREATE TABLE, or run every migration step from version 0
The second fix is safer: one ordered list of steps used by both callbacks cannot drift apart the way two hand-written schemas do
Why tests missed it: the developer's device was upgraded, never installed fresh; CI should open an empty database and compare PRAGMA table_info with an upgraded one
Hive keeps objects in boxes: files whose keys are strings or ints. To save your own class it needs a TypeAdapter that knows how to read and write every field; you write it by hand or generate it from annotations.
**One typeId per class: unique in the app and never changed, because it is written into every stored record
Field numbers are forever**: never reuse or renumber a @HiveField; to remove a field, keep its number reserved and stop reading it
Encryption is AES: Hive.openBox(name, encryptionCipher: HiveAesCipher(key)) with a 256-bit key from Hive.generateSecureKey()
**Store that key in flutter_secure_storage**: never in SharedPreferences, or the lock sits next to its key
Which package: hive 2 has had no stable release in years; new projects take the community fork hive_ce, same API
It is a thin wrapper over the platform's own secure store, so the real guarantee is the platform's: the Keychain on iOS, a Keystore key on Android.
iOS: the Keychain, hardware-backed on modern devices; the default accessibility means readable only while the phone is unlocked, so a background task needs IOSOptions(accessibility: KeychainAccessibility.first_unlock)
iOS surprise: Keychain items survive an uninstall, so a reinstall can find the previous user's token; delete them on a first-launch flag
Android: since v10 the plugin keeps an RSA key in the Android Keystore that wraps an AES-GCM data key, replacing the deprecated Jetpack Security EncryptedSharedPreferences; plain AndroidOptions() is the right default
What it does not stop: a rooted device with the screen unlocked can still ask the keystore to decrypt
Keep it small: credentials and keys, not files; reads are async and can throw a PlatformException, for example on a locked device
Choose Drift when you want compile-checked queries, streams that re-emit when tables change and testable migrations; stay on raw sqflite for a handful of queries.
Type-safe queries: a wrong column name fails to compile instead of crashing at runtime
Watchable queries: .watch() turns any query into a stream that re-emits when the tables it reads change
Migrations you can test: versioned steps plus generated schema snapshots to run them against
One code path everywhere: mobile, desktop and web (sqlite3 compiled to WASM)
Stay on raw sqflite when: you have a handful of queries and do not want a build_runner step
Drift and Hive are not rivals: drift is for relational queries and joins, Hive for fetching an object by key.
Isar and ObjectBox are NoSQL object databases written in native code with Dart bindings: you store Dart objects directly instead of mapping them onto tables and rows.
Why they are fast: memory-mapped reads with no copy and no deserialisation, plus real indexes
Where they beat sqflite: thousands of writes a second, deep object graphs you would otherwise flatten into joins, built-in full-text indexes (Isar)
They are reactive: subscribe to a query and get a stream of results
The cost: a bigger app (a native library per ABI) and a smaller ecosystem than SQLite
Maintenance risk: Isar has had no stable release since 2023, so in practice you take the isar_community fork; ObjectBox is still actively released
Rule of thumb: SQLite or drift for relational data, an object database for a hot object store where SQL is overkill.
Inside the transaction it calls db.rawQuery instead of txn.rawQuery. sqflite queues every call on db until the open transaction finishes, and the transaction is waiting for that call, so it waits for itself forever.
Why: a sqflite Database runs its operations one at a time; while a transaction is open, only its txn object may use the connection
The symptom: no exception, just a hang and the warning database has been locked for 0:00:10.000000. Make sure you always use the transaction object for database operations during a transaction
The fix: every call inside the callback goes through txn, including calls in helper functions: make them take a DatabaseExecutor, the common supertype of Database and Transaction
The disguised version: awaiting a repository method that uses the shared db from inside a transaction does exactly the same
openDatabase returns one shared Database per path, so the close() in one call's finally closes it under every other call that is in flight and has not yet reached the database; those calls fail with a database-closed DatabaseException (calls that start later simply reopen it).
Why tests pass: calls that run one after another each open and close in turn; it breaks only when two calls overlap, e.g. a screen loads the list while a save or a background sync is running
Why the instance is shared: sqflite caches one open connection per path and hands it to every openDatabase caller; close() closes that connection and drops it from the cache, not a private handle
The fix: open once and keep the connection for the app's lifetime; caching the Future<Database> instead of the Database also makes two simultaneous first calls share a single open. If the first open fails, clear _db so the next call retries. Close only on shutdown or logout
Cost even without the crash: every call goes through the per-path open lock and a native round trip (plus onConfigure/onOpen if set), so per-call open and close adds latency to each query
Catch it next time: failure depends on timing: a test on sqflite_common_ffi that fires three overlapping calls, Future.wait([dao.all(), dao.add('a'), dao.all()]), fails reliably, while two overlapping calls can pass; in review, treat any close() outside an app-level dispose as suspect
await only yields between pieces of work, it does not move them off the isolate: the freeze is synchronous Dart code, the jsonDecode of 20 MB and the loop that builds models.
**jsonDecode of 20 MB**: one long synchronous block of Dart; the await in front of the caller changes nothing
Channel traffic: a per-row await db.insert does not freeze frames, since it yields after each row, but one platform hop per row makes the import long; one giant Batch is encoded in one synchronous chunk, so commit in chunks of a few thousand rows
The fix: do only the CPU work in Isolate.run (decode into plain maps) and write on the UI isolate in chunks of 2000 with db.batch() and commit(noResult: true)
Keep sqflite on the main isolate: sqflite recommends main-isolate access because its transactions are not cross-isolate safe; if a second isolate is unavoidable, use singleInstance: false in both and never close the database there
Storage bloat is almost always a cache nobody gave an eviction rule. Measure every directory first, then put a budget on each bucket.
Measure: walk the app directories with Directory.list(recursive: true), sum File.stat().size per bucket (images, downloads, database, logs) and send it as a metric
The usual culprits: an image cache with no cap, downloaded media nobody deletes, .part files from failed downloads, logs and queues nothing trims
Regenerable data belongs in cache or temp: the OS may purge it and backups skip it; the same file in Documents is also uploaded to iCloud
A database only grows: deleted rows leave free pages, so run VACUUM occasionally and checkpoint the WAL file
Evict least-recently-used: keep an index of path, size and last access, trim on a schedule rather than mid-scroll, and ship a visible "clear cache" action
sqflite never tells anyone that something changed, so either every write goes through one object that also emits, or you move to a store that watches tables for you.
One owner: put reads and writes behind a repository; after a write it re-reads and pushes the new value into a broadcast StreamController
The rule: nothing outside the repository touches the database; one stray db.update elsewhere and the screen is stale again
Seed the stream: a bare broadcast stream gives a new listener nothing until the next write
Stores that do it for you: drift's .watch(), Hive's box.watch(), Isar and ObjectBox queries; drift only notices writes made through drift
Common mistake: calling repo.watchOpen() inside build() gives a new stream and a new query on every rebuild; create it once in initState (a manual listen also needs cancel in dispose)
Logging out means destroying every copy of that user's data on the device, not just navigating to the sign-in screen.
The obvious part: tokens in secure storage, the user's rows, cached responses, downloaded files, per-user preference keys (keep theme and locale)
In-memory state: singletons, blocs and DI scopes still hold the old user; recreate the user scope or the next login shows the previous account
The sync queue: flushed after the next login, it sends one user's writes under another user's token
The iOS Keychain survives an uninstall: a reinstall can resurrect the old token; wipe secure storage when the first-launch flag in preferences is gone
Also forgotten: the image cache, WebView cookies, the push token, the analytics user id
Order: revoke on the server best-effort, then wipe locally even if that failed: logout must work offline
A local database looks single-user, but the UI, a sync worker and a notification handler all write to it at the same time, often from different isolates.
Lost updates: two read-then-write pairs on the same row: the second overwrites the first and nobody notices
A writer blocks readers: SQLite locks the file while writing; turn on WAL (PRAGMA journal_mode=WAL) so one writer and many readers stop waiting for each other
Two connections, one file: drift or sqlite3 opened in two isolates gives two connections; SQLite keeps the file safe, but competing writers get database is locked (SQLITE_BUSY), so route writes through one connection (drift's DriftIsolate) or set a busy timeout
sqflite's own deadlock: inside db.transaction, any call on db instead of txn waits for the transaction that is waiting for it
The defences: multi-statement writes in one transaction, all writes through one repository, short transactions with no network call inside
Optimistic concurrency: keep a revision column and update with WHERE id = ? AND revision = ?; zero rows changed means someone else won
Run EXPLAIN QUERY PLAN on the statement first: at that size the answer is nearly always that SQLite is scanning the table where it should be searching an index.
SCAN orders means every row is read; SEARCH orders USING INDEX ... is what you want. USE TEMP B-TREE FOR ORDER BY means the sort is unindexed too.
Add the index: on the columns in WHERE, JOIN and ORDER BY; in a composite index the equality columns come first, the range or sort column last
Keep it usable: a leading % in LIKE, a function on the column, or a number stored as text all disable it
Kill the N+1: every sqflite call crosses a platform channel, so a query per row in a loop is brutal; use a join, an IN (...), or a Batch
Page in SQL: LIMIT with a keyset condition beats OFFSET, and select only the columns you need
The other half of those two seconds: rows still cross the channel and become Dart objects on the UI isolate, so 50 000 rows jank whatever the plan says
A restore brings back your files but not device-bound keys (Android Keystore keys never leave the device), so the new phone gets an encrypted database whose key no longer exists.
What is backed up: on iOS Documents/ and Library/Application Support/, not the caches; on Android, Auto Backup takes files, databases and shared_prefs by default
The keys stay behind: Keystore keys never leave the device, Keychain items return only from an encrypted backup, and *_this_device items never migrate, so the file opens as "file is not a database"
**A WAL database without its -wal: restores as a snapshot from the last checkpoint, quietly missing the newest rows
A duplicated install id: an id generated once and stored in Documents now lives on two phones
What to do**: keep regenerable data in the cache directory, exclude the encrypted database from Android backup in data_extraction_rules.xml, and treat a failed decrypt as a fresh install: wipe and re-sync
Treat it as a one-way data migration that runs at most once per install, survives the app being killed, and is verified before you delete the old copy.
Ship both stores: the new one next to the old one, with the migration marker as a row in the new database, written in the same transaction as the last chunk of data
Copy in chunks: otherwise a kill between "copied" and "marked" restarts the whole thing
Assume it runs twice: make every step idempotent (ConflictAlgorithm.replace) and guard the entry point with a lock
Not on the first frame: 200 000 records on a cheap phone takes seconds, so do it in an isolate behind a progress screen
Count records in and out: log the delta and alert on it; "no crash reports" is not "no data loss"
**hive to hive_ce**: same box files and adapters, so it is a dependency swap, not a data copy
A background handler runs in its own isolate with its own Dart memory, so any store that caches state in Dart ends up with two copies that go stale and overwrite each other.
**Legacy SharedPreferences.getInstance()**: loads every key into a Dart map once and answers reads from it; the background write does reach the platform, but the UI isolate serves its old map until something calls reload()
Hive: keeps a box in memory per isolate and appends to the file; both isolates believe they own it, and a compaction from one can drop what the other wrote
sqflite is the exception: the connection lives on the platform side, so both isolates talk to the same native database and see each other's writes (with the default singleInstance: true; transactions are still not cross-isolate safe)
The fix: SharedPreferencesAsync has no Dart cache and goes to the platform on every call; for Hive, hive_ce's IsolatedHive keeps the boxes in one isolate
An isolate you spawn yourself: cannot use plugins until BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken); the FCM background handler on Android is different, because the plugin starts a separate background engine with plugins already registered
SQLite almost never corrupts itself, so read every one of those reports as something outside SQLite touching the file, and design so that losing the local database is an inconvenience, not data loss.
**Separated -wal and -shm: a hand-rolled export, backup or restore that took the main file and not its siblings
The wrong directory: a database in cache or temp that the OS deleted under storage pressure while it was open
Full disk: a write at almost zero free space can leave truncated pages; log free space with the error
Look-alikes, not corruption**: a locked iOS device with NSFileProtectionComplete gives an I/O or open_failed error, and a half-done migration gives wrong data, not a malformed image; filter them out by error code first
Diagnose: log the extended result code, the journal mode, the directory, the file size and free disk space; run PRAGMA quick_check on the launch after a crash, not on every launch
Recover, never repair in place: move the file and its siblings aside, open a fresh database, re-sync from the server, and tell the user if anything was local-only