Lesson 25 of 77 intermediate

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

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. 1. AppSettings wraps SharedPreferences with typed getters/setters — no raw key strings in calling code
  2. 2. Version your SharedPreferences keys (_onboardedKey = 'onboarding_v2_complete') to allow resets on schema changes
  3. 3. @HiveType(typeId: 1) registers this class with Hive's type system — typeId must be globally unique
  4. 4. @HiveField indices map binary positions to Dart fields — never reorder, always add at the end
  5. 5. The Hive Box is typed — getById() returns CachedSurveyTemplate?, not dynamic
  6. 6. isStale() encapsulates cache freshness logic in the repository layer, not the ViewModel
  7. 7. @Entity maps the Dart class to a SQLite table — Floor generates the CREATE TABLE SQL
  8. 8. getUnsyncedResponses() is the key query for the sync engine — finds all responses pending upload
  9. 9. @transaction ensures batch inserts are atomic — either all succeed or all roll back
  10. 10. Migration from v1 to v2 adds a column with a default value — safe for existing users
  11. 11. saveTokens() uses Future.wait for parallel writes — both tokens written simultaneously
  12. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery