Local Persistence: SharedPreferences vs Hive vs SQLite vs Floor
Choosing the Right Storage for the Right Job
Open interactive version (quiz + challenge)Real-world analogy
Local storage options are like different filing systems. SharedPreferences is a sticky-note pad — great for 'dark mode on', terrible for thousands of records. Hive is a fast filing cabinet with typed drawers — structured but not relational. SQLite is a filing room with cross-reference cards between folders — slow to set up, powerful for complex queries. Floor is SQLite with a librarian who organizes everything for you via code generation.
What is it?
Flutter provides multiple local persistence options: SharedPreferences (simple key-value), Hive (fast NoSQL), SQLite via sqflite/Floor (relational with SQL), and flutter_secure_storage (encrypted secrets). Choosing the right tool depends on data structure complexity, query requirements, performance needs, and sensitivity.
Real-world relevance
In an offline-first field survey app: flutter_secure_storage stores the user's auth token. SharedPreferences stores the user's app settings (locale, last sync time). Hive caches the list of survey templates downloaded from the server. Floor stores the actual survey responses with complex relational structure (survey → questions → answers) that needs SQL querying for reporting.
Key points
- SharedPreferences — Simple Key-Value — SharedPreferences stores primitive values (String, int, double, bool, List) as key-value pairs using platform-native storage (NSUserDefaults on iOS, SharedPreferences on Android). Synchronous-feeling async API. Best for: user settings, theme, language, onboarding flags, last-used values. Terrible for: collections, structured data, anything with queries.
- Hive — Fast NoSQL — Hive stores Dart objects in binary format. Extremely fast (O(1) reads). Supports custom objects via TypeAdapters (code-generated). No SQL, no relations — it's a key-value store for complex objects. Great for: offline caching, user data, feature flags, settings more complex than SharedPreferences. Hive boxes are lazy-loaded — open only what you need.
- SQLite — Full Relational DB — SQLite is a full relational database embedded in every mobile device. Supports SQL queries, JOINs, transactions, complex filtering, indexing. The sqflite package provides raw SQL access. Best for: complex relational data, reporting, filtering, sorting at the database level. Drawbacks: verbose SQL strings, no type safety, manual migration management.
- Floor — Type-Safe SQLite ORM — Floor (inspired by Android Room) generates SQLite code from Dart annotations. Define entities (@Entity), DAOs (@Dao), and the database (@Database). Build runner generates the implementation. Best for: relational data that needs type safety and tested query infrastructure. Preferred over raw sqflite in production for complex schemas.
- Isar — The Modern Alternative — Isar is a newer NoSQL database (successor to Hive): faster than Hive, supports indexes and filtering, works with Isar Inspector (visual DB browser during development). Async by default with full ACID transactions. Growing adoption as a sqflite replacement for structured data without the relational complexity.
- Performance Characteristics — SharedPreferences: slowest for bulk data (writes XML/plist files). Hive: fastest for key-value reads (memory-mapped binary). SQLite: fast for indexed queries, slow for full table scans on large tables. The benchmark matters for offline-first apps loading large datasets on startup — choose Hive or Isar for read-heavy caching, SQLite/Floor for relational data.
- Migration Strategies — SharedPreferences: version keys (settings_v2). Hive: TypeAdapter versioning with @HiveField(index) — add fields at the end, never reorder. SQLite/Floor: database version number with migration callbacks. Floor: @migration class with SQL ALTER TABLE. Missing migrations are the #1 cause of production crashes on app updates.
- Encrypted Storage — For sensitive data (tokens, PII, medical records): flutter_secure_storage (iOS Keychain, Android Keystore) for small secrets. encrypted_shared_preferences for encrypted key-value. SQLCipher for encrypted SQLite databases. Hive supports encryption with HiveCipher — AES-CBC with a password. Never store tokens in plain SharedPreferences or Hive without encryption.
- Local Database as Single Source of Truth — In offline-first architecture, the local database IS the source of truth. The UI reads from DB, not from API responses directly. The sync layer writes API responses to DB, and the DB layer notifies the UI via streams. This pattern (popularized by SQLDelight on Android) is what makes apps feel snappy and work offline.
- When to Use Multiple Storage Solutions — Production apps commonly use multiple storage layers: flutter_secure_storage for tokens, SharedPreferences for simple settings, Hive for API response cache, SQLite/Floor for complex offline data. Each tool for its strength. The Repository pattern abstracts this so the domain layer never knows which storage is used.
- Testing Local Storage — Use in-memory implementations for unit tests: Hive with Hive.init(tempDir) in setUp(). Floor with an in-memory database (openDatabase(':memory:')). SharedPreferences with SharedPreferences.setMockInitialValues({}). Never test business logic against real platform storage in unit tests.
Code example
// === SharedPreferences — Simple Settings ===
import 'package:shared_preferences/shared_preferences.dart';
class AppSettings {
static const _themeKey = 'theme_dark';
static const _localeKey = 'locale';
static const _onboardedKey = 'onboarding_v2_complete'; // Version your keys!
final SharedPreferences _prefs;
const AppSettings(this._prefs);
bool get isDarkMode => _prefs.getBool(_themeKey) ?? true;
Future<void> setDarkMode(bool value) => _prefs.setBool(_themeKey, value);
String get locale => _prefs.getString(_localeKey) ?? 'en';
Future<void> setLocale(String locale) => _prefs.setString(_localeKey, locale);
bool get isOnboarded => _prefs.getBool(_onboardedKey) ?? false;
Future<void> markOnboarded() => _prefs.setBool(_onboardedKey, true);
}
// === Hive — Fast NoSQL for Cached Objects ===
import 'package:hive_flutter/hive_flutter.dart';
// 1. Define a type-safe adapter
@HiveType(typeId: 1)
class CachedSurveyTemplate extends HiveObject {
@HiveField(0) late String id;
@HiveField(1) late String title;
@HiveField(2) late List<String> questionIds;
@HiveField(3) late DateTime cachedAt;
// RULE: add new fields at the END, never reorder existing ones!
@HiveField(4) String? version; // Added in v2 — safe to add
}
// 2. Setup in main()
Future<void> setupHive() async {
await Hive.initFlutter();
Hive.registerAdapter(CachedSurveyTemplateAdapter());
}
// 3. Repository using Hive
class SurveyTemplateCache {
static const _boxName = 'survey_templates';
late Box<CachedSurveyTemplate> _box;
Future<void> init() async => _box = await Hive.openBox(_boxName);
Future<void> saveTemplates(List<CachedSurveyTemplate> templates) async {
await _box.putAll({for (var t in templates) t.id: t});
}
List<CachedSurveyTemplate> getAll() => _box.values.toList();
CachedSurveyTemplate? getById(String id) => _box.get(id);
bool isStale(String id, Duration maxAge) {
final cached = _box.get(id);
if (cached == null) return true;
return DateTime.now().difference(cached.cachedAt) > maxAge;
}
}
// === Floor — Type-Safe SQLite ORM ===
import 'package:floor/floor.dart';
// 1. Entity
@Entity(tableName: 'survey_responses')
class SurveyResponseEntity {
@PrimaryKey(autoGenerate: false)
final String id;
final String surveyId;
final String questionId;
final String answer;
final int createdAtMs;
final bool isSynced;
const SurveyResponseEntity({
required this.id,
required this.surveyId,
required this.questionId,
required this.answer,
required this.createdAtMs,
this.isSynced = false,
});
}
// 2. DAO
@dao
abstract class SurveyResponseDao {
@Query('SELECT * FROM survey_responses WHERE survey_id = :surveyId ORDER BY created_at_ms ASC')
Future<List<SurveyResponseEntity>> getResponsesForSurvey(String surveyId);
@Query('SELECT * FROM survey_responses WHERE is_synced = 0')
Future<List<SurveyResponseEntity>> getUnsyncedResponses();
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insertResponse(SurveyResponseEntity response);
@Query('UPDATE survey_responses SET is_synced = 1 WHERE id = :id')
Future<void> markSynced(String id);
@transaction
Future<void> saveResponsesBatch(List<SurveyResponseEntity> responses) async {
for (final r in responses) await insertResponse(r);
}
}
// 3. Database with Migration
@Database(version: 2, entities: [SurveyResponseEntity])
abstract class AppDatabase extends FloorDatabase {
SurveyResponseDao get surveyResponseDao;
static final migration1to2 = Migration(1, 2, (database) async {
await database.execute('ALTER TABLE survey_responses ADD COLUMN is_synced INTEGER NOT NULL DEFAULT 0');
});
}
// 4. Initialize with migration
Future<AppDatabase> buildDatabase() async {
return await $FloorAppDatabase
.databaseBuilder('app_database.db')
.addMigrations([AppDatabase.migration1to2])
.build();
}
// === flutter_secure_storage — Encrypted Secrets ===
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureTokenStorage {
final FlutterSecureStorage _storage;
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
const SecureTokenStorage(this._storage);
Future<void> saveTokens({required String access, required String refresh}) async {
await Future.wait([
_storage.write(key: _accessTokenKey, value: access),
_storage.write(key: _refreshTokenKey, value: refresh),
]);
}
Future<String?> getAccessToken() => _storage.read(key: _accessTokenKey);
Future<String?> getRefreshToken() => _storage.read(key: _refreshTokenKey);
Future<void> clearTokens() => _storage.deleteAll();
}Line-by-line walkthrough
- 1. AppSettings wraps SharedPreferences with typed getters/setters — no raw key strings in calling code
- 2. Version your SharedPreferences keys (_onboardedKey = 'onboarding_v2_complete') to allow resets on schema changes
- 3. @HiveType(typeId: 1) registers this class with Hive's type system — typeId must be globally unique
- 4. @HiveField indices map binary positions to Dart fields — never reorder, always add at the end
- 5. The Hive Box is typed — getById() returns CachedSurveyTemplate?, not dynamic
- 6. isStale() encapsulates cache freshness logic in the repository layer, not the ViewModel
- 7. @Entity maps the Dart class to a SQLite table — Floor generates the CREATE TABLE SQL
- 8. getUnsyncedResponses() is the key query for the sync engine — finds all responses pending upload
- 9. @transaction ensures batch inserts are atomic — either all succeed or all roll back
- 10. Migration from v1 to v2 adds a column with a default value — safe for existing users
- 11. saveTokens() uses Future.wait for parallel writes — both tokens written simultaneously
- 12. clearTokens() deletes ALL secure storage — used on logout to leave no credentials on device
Spot the bug
@HiveType(typeId: 0)
class UserSettings extends HiveObject {
@HiveField(2) late String theme;
@HiveField(0) late String locale;
@HiveField(1) late bool notificationsEnabled;
}
// v2 — adding a new field
@HiveType(typeId: 0)
class UserSettings extends HiveObject {
@HiveField(0) late String locale;
@HiveField(1) late bool notificationsEnabled;
@HiveField(2) late String theme;
@HiveField(3) late String? displayName; // new field
}Need a hint?
The indices changed between v1 and v2. What happens to existing stored data?
Show answer
In v1, @HiveField(2) is 'theme'. In v2, @HiveField(2) is still 'theme' — OK. But the index ORDER in the source code changed (0,1,2 vs 2,0,1 in v1). Hive uses the numeric index value, not the declaration order, so this is actually safe IF the numeric values map to the same fields. However, this is a code readability and maintenance trap — always declare fields in index order to avoid confusion. The real risk: if a developer swapped indices (e.g., changed @HiveField(0) from 'locale' to 'theme'), existing stored data would silently assign the old 'locale' bytes to the 'theme' field, corrupting all user settings. Always add new fields at the next available index, never change existing field-to-index mappings.
Explain like I'm 5
Storing data locally is like choosing where to keep notes at school. Sticky notes on your desk (SharedPreferences) — great for 'homework due Friday', terrible for a whole textbook. A fast binder with tabs (Hive) — good for structured notes, but you can't easily cross-reference between sections. A library with an index system (SQLite) — slow to set up, but you can find any book by author, topic, or year. Floor is SQLite but the librarian builds the index for you automatically.
Fun fact
SQLite is the most widely deployed database engine in the world — more deployments than all other databases combined. It's used in every iOS and Android device, every Chrome browser, every Firefox installation, and most aircraft. The Flutter app you ship stores data in the same database engine used by the space station.
Hands-on challenge
Design the storage strategy for an offline-first field survey app with 3 user roles, survey templates, and survey responses. Specify: (1) what goes in flutter_secure_storage, (2) what goes in SharedPreferences, (3) what goes in Hive, (4) what goes in Floor, and (5) the migration strategy for adding a 'gps_coordinates' field to survey responses in v2.
More resources
- SharedPreferences Package (pub.dev)
- Hive Documentation (Hive Docs)
- Floor Package (pub.dev)
- flutter_secure_storage (pub.dev)
- Isar Database (Isar Docs)