Local Storage & Database Management
Effective local data storage is crucial for creating responsive, offline-capable Flutter applications. This guide covers choosing and implementing the right storage solution for your needs.
Storage Solutions Overview
Section titled “Storage Solutions Overview”Quick decision guide:
- Settings & preferences → SharedPreferences
- Simple object storage → Hive
- Complex relationships → Drift (SQLite)
- Raw SQL control → SQFlite
Storage Options
Section titled “Storage Options”SharedPreferences - Simple key-value storage for primitive data. Perfect for user settings and small data pieces.
Hive - Fast NoSQL database built for Flutter. Great for moderate-sized structured data without relationships.
Drift (formerly Moor) - Type-safe SQLite wrapper with code generation. Ideal for relational data with compile-time safety.
SQFlite - Direct SQLite bindings for Flutter. Provides low-level control for complex SQL operations.
Choosing the Right Storage Solution
Section titled “Choosing the Right Storage Solution”Decision framework:
-
Basic Configuration or Simple Data Use SharedPreferences for small, non-structured data like settings and flags.
-
Moderate Structured Data Choose Hive for storing objects without complex relationships when performance is critical.
-
Relational Data with Type Safety Select Drift for complex, relational data that benefits from compile-time checking.
-
Complex SQL Requirements Use SQFlite when you need direct SQL control or are migrating from an existing SQLite database.
SharedPreferences - Simple Key-Value Storage
Section titled “SharedPreferences - Simple Key-Value Storage”Best for: User settings, app preferences, simple flags
Basic Setup and Usage
Section titled “Basic Setup and Usage”// pubspec.yamldependencies:shared_preferences: ^2.2.0
// Storing different data typesFuture<void> saveUserPreferences() async {final prefs = await SharedPreferences.getInstance();
// Store basic typesawait prefs.setString('username', 'flutter_user');await prefs.setBool('darkMode', true);await prefs.setInt('launchCount', 10);await prefs.setStringList('recentSearches', ['flutter', 'dart']);}
// Retrieving values with defaultsFuture<void> loadUserPreferences() async {final prefs = await SharedPreferences.getInstance();
final username = prefs.getString('username') ?? 'Guest';final isDarkMode = prefs.getBool('darkMode') ?? false;final launchCount = prefs.getInt('launchCount') ?? 0;
// Increment launch countawait prefs.setInt('launchCount', launchCount + 1);}
Best Practices
Section titled “Best Practices”Use constants for keys to avoid typos:
class PreferenceKeys {static const String username = 'username';static const String darkMode = 'darkMode';static const String launchCount = 'launchCount';}
// Usageawait prefs.setBool(PreferenceKeys.darkMode, true);
Create a service class for better organization:
class PreferencesService {late SharedPreferences _prefs;
Future<void> init() async { _prefs = await SharedPreferences.getInstance();}
// GettersString get username => _prefs.getString(PreferenceKeys.username) ?? 'Guest';bool get isDarkMode => _prefs.getBool(PreferenceKeys.darkMode) ?? false;
// SettersFuture<void> setUsername(String value) => _prefs.setString(PreferenceKeys.username, value);Future<void> setDarkMode(bool value) => _prefs.setBool(PreferenceKeys.darkMode, value);}
Limitations of SharedPreferences:
- Only supports primitive types (bool, int, double, String) and String lists
- Not suitable for structured data or objects
- Not designed for storing large amounts of data
- Limited thread safety for high-frequency operations
Structured Storage with Hive
Section titled “Structured Storage with Hive”Hive is a lightweight, high-performance NoSQL database optimized for Flutter applications.
// pubspec.yamldependencies:hive: ^2.2.3hive_flutter: ^1.1.0
import 'package:hive/hive.dart';
@HiveType(typeId: 0)class Task {@HiveField(0)final String id;
@HiveField(1)String title;
@HiveField(2)bool completed;
Task({required this.id, required this.title, this.completed = false});}
// Initialize and use Hiveawait Hive.initFlutter();Hive.registerAdapter(TaskAdapter());final taskBox = await Hive.openBox<Task>('tasks');
// CRUD operationsawait taskBox.put(task.id, task); // Create/Updatefinal task = taskBox.get(id); // Readawait taskBox.delete(id); // Delete
Best Practices for Hive
Section titled “Best Practices for Hive”Hive Best Practices:
- Use unique typeIds for each model class (0-223)
- Implement repository pattern to encapsulate database logic
- Close boxes when they’re no longer needed
- Use Hive’s lazy boxes for large collections
- Consider encryption for sensitive data with
hive_flutter.encryptionCipher
When Not to Use Hive:
- When you need complex relational queries
- When data consistency is critical during crashes
- For very large datasets where indexing is important
SQL Storage with Drift
Section titled “SQL Storage with Drift”Drift is a reactive persistence library for Dart & Flutter applications that builds on top of SQLite.
// pubspec.yamldependencies:drift: ^2.8.0sqlite3_flutter_libs: ^0.5.15
// Define table and databaseclass Tasks extends Table {IntColumn get id => integer().autoIncrement()();TextColumn get title => text()();BoolColumn get completed => boolean().withDefault(const Constant(false))();}
@DriftDatabase(tables: [Tasks])class AppDatabase extends _$AppDatabase {AppDatabase() : super(_openConnection());
@overrideint get schemaVersion => 1;
// CRUD operationsFuture<List<Task>> getAllTasks() => select(tasks).get();Stream<List<Task>> watchAllTasks() => select(tasks).watch();Future<int> createTask(TasksCompanion task) => into(tasks).insert(task);}
// Reactive UI updatesStreamBuilder<List<Task>>(stream: database.watchAllTasks(),builder: (context, snapshot) { final tasks = snapshot.data ?? []; return ListView.builder( itemCount: tasks.length, itemBuilder: (context, index) => ListTile( title: Text(tasks[index].title), leading: Checkbox( value: tasks[index].completed, onChanged: (value) => database.updateTask( tasks[index].copyWith(completed: Value(value ?? false)) ), ), ), );},)
Best Practices for Drift
Section titled “Best Practices for Drift”Drift Best Practices:
- Define database schema in a dedicated file
- Use migrations for schema updates
- Leverage reactive streams for UI updates
- Implement repository pattern to abstract database operations
- Use transactions for related operations
// Example of using transactions in DriftFuture<void> transferFunds(int fromAccount, int toAccount, double amount) {return database.transaction(() async { // Deduct from source account await (update(accounts)..where((a) => a.id.equals(fromAccount))) .write(AccountsCompanion( balance: Expression.custom('balance - $amount') ));
// Add to destination account await (update(accounts)..where((a) => a.id.equals(toAccount))) .write(AccountsCompanion( balance: Expression.custom('balance + $amount') ));
// Create transaction record await into(transactions).insert( TransactionsCompanion.insert( fromAccountId: fromAccount, toAccountId: toAccount, amount: amount, date: DateTime.now(), ) );});}
SQL Storage with SQFlite
Section titled “SQL Storage with SQFlite”SQFlite provides direct access to SQLite functionality in Flutter applications.
// pubspec.yamldependencies:sqflite: ^2.2.8+4path: ^1.8.3
// Database helper singletonclass DatabaseHelper {static final DatabaseHelper instance = DatabaseHelper._init();static Database? _database;
DatabaseHelper._init();
Future<Database> get database async { if (_database != null) return _database!; _database = await _initDB('app_database.db'); return _database!;}
Future<Database> _initDB(String filePath) async { final dbPath = await getDatabasesPath(); final path = join(dbPath, filePath);
return await openDatabase(path, version: 1, onCreate: _createDB);}
Future<void> _createDB(Database db, int version) async { await db.execute(''' CREATE TABLE tasks( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, completed INTEGER NOT NULL ) ''');}}
// CRUD operationsFuture<int> createTask(String title) async {final db = await database;return await db.insert('tasks', {'title': title, 'completed': 0});}
Future<List<Map<String, dynamic>>> readAllTasks() async {final db = await database;return await db.query('tasks');}
Future<int> updateTask(int id, bool completed) async {final db = await database;return await db.update( 'tasks', {'completed': completed ? 1 : 0}, where: 'id = ?', whereArgs: [id],);}
Best Practices for SQFlite
Section titled “Best Practices for SQFlite”SQFlite Best Practices:
- Use a singleton pattern for database helper
- Define SQL queries as constants
- Handle migrations carefully for schema changes
- Close database connections when no longer needed
- Use transactions for complex operations
Drift vs SQFlite:
- Drift provides compile-time type safety, eliminating SQL string errors
- Drift generates boilerplate code for you
- SQFlite gives more direct control over SQL queries
- SQFlite has a lower learning curve for developers familiar with SQL
Offline Data Handling
Section titled “Offline Data Handling”Implementing effective offline capabilities ensures your app remains functional without an internet connection.
- Local Persistence - Store all essential data locally for offline access
- Sync Management - Track changes made offline to sync later
- Conflict Resolution - Define strategies for handling conflicts during synchronization
- UI Feedback - Inform users about offline mode and sync status
Synchronization Strategies
Section titled “Synchronization Strategies”class BasicSyncService {final ApiService _api;final TaskRepository _repository;
BasicSyncService(this._api, this._repository);
Future<void> syncTasks() async { try { // Download remote changes final remoteTasks = await _api.fetchTasks(); for (var task in remoteTasks) { await _repository.saveTask(task); }
// Upload local changes final localTasks = await _repository.getPendingUploads(); for (var task in localTasks) { await _api.uploadTask(task); await _repository.markAsSynced(task.id); } } catch (e) { // Handle connectivity issues print('Sync failed: $e'); }}}
When to Use:
- For simple data models
- When conflicts are rare
- For apps with limited offline editing
class TimestampSyncService {final ApiService _api;final TaskRepository _repository;
TimestampSyncService(this._api, this._repository);
Future<void> syncTasks() async { try { // Get last sync timestamp final lastSync = await _repository.getLastSyncTimestamp();
// Get remote changes since last sync final remoteChanges = await _api.getChangesSince(lastSync); for (var change in remoteChanges) { // Apply remote changes await _repository.applyRemoteChange(change); }
// Upload local changes final localChanges = await _repository.getChangesSince(lastSync); for (var change in localChanges) { await _api.uploadChange(change); }
// Update sync timestamp await _repository.setLastSyncTimestamp(DateTime.now()); } catch (e) { // Handle sync errors }}}
When to Use:
- For collaborative apps
- When you need to track change history
- For data with frequent updates
class QueueSyncService {final ApiService _api;final SyncQueueRepository _queueRepo;
QueueSyncService(this._api, this._queueRepo);
// Add operation to queue when offlineFuture<void> addTask(Task task) async { await _queueRepo.addToQueue( SyncOperation( type: SyncOperationType.create, entityType: 'task', data: task.toMap(), timestamp: DateTime.now(), ), );
// Try to sync immediately processQueue();}
// Process the sync queueFuture<void> processQueue() async { if (!await _hasConnectivity()) return;
final operations = await _queueRepo.getOperationsToSync(); for (var op in operations) { try { switch (op.type) { case SyncOperationType.create: await _api.create(op.entityType, op.data); break; case SyncOperationType.update: await _api.update(op.entityType, op.data); break; case SyncOperationType.delete: await _api.delete(op.entityType, op.data['id']); break; }
// Mark as synced await _queueRepo.markAsSynced(op.id); } catch (e) { // If operation fails, keep in queue for retry if (!_isServerError(e)) { // Mark as failed if client error await _queueRepo.markAsFailed(op.id, e.toString()); } } }}
Future<bool> _hasConnectivity() async { // Check for internet connectivity}
bool _isServerError(Exception e) { // Determine if error is server-side (5xx) or client-side (4xx)}}
When to Use:
- For mission-critical data
- When order of operations matters
- For apps with extensive offline functionality
Conflict Resolution Strategies
Section titled “Conflict Resolution Strategies”Server Wins
Section titled “Server Wins”Always prefer server data over local data during conflicts. Simple to implement but may result in lost offline changes.
Client Wins
Section titled “Client Wins”Local changes override server data during conflicts. Preserves user changes but may overwrite others’ updates in collaborative scenarios.
Last Write Wins
Section titled “Last Write Wins”Use timestamps to determine which change is more recent. Requires synchronized clocks but provides a logical resolution approach.
Manual Merge
Section titled “Manual Merge”Present conflicts to users and let them decide how to resolve them. Most user-friendly but increases complexity significantly.
Storage Migration Strategies
Section titled “Storage Migration Strategies”As your app evolves, you may need to migrate from one storage solution to another:
- Dual Write Period - Write to both old and new storage systems during transition
- Data Migration - Transfer existing data from old to new storage
- Validation - Verify data integrity in the new system
- Switch Read Path - Start reading from the new storage system
- Cleanup - Remove old storage system once migration is stable
// Migration service exampleclass StorageMigrationService {final SharedPreferences _prefs;final TaskRepository _hiveRepo;
Future<void> migrateTasksToHive() async { final migrated = _prefs.getBool('migration_completed') ?? false; if (migrated) return;
try { // Get data from old storage final taskJson = _prefs.getString('tasks') ?? '[]'; final tasks = jsonDecode(taskJson);
// Migrate to new storage for (var taskMap in tasks) { await _hiveRepo.addTask(Task.fromMap(taskMap)); }
// Mark as completed await _prefs.setBool('migration_completed', true); } catch (e) { print('Migration failed: $e'); }}}
Best Practices Summary
Section titled “Best Practices Summary”General Storage Best Practices
Section titled “General Storage Best Practices”Overall Storage Guidelines:
- Choose the right tool for each data type
- Separate storage logic into repository classes
- Implement proper error handling for storage operations
- Use transactions for related operations
- Always handle migration paths for schema changes
- Implement data backup strategies for critical user data
Common Mistakes to Avoid:
- Storing large data structures in SharedPreferences
- Performing database operations on the UI thread
- Neglecting to close database connections
- Overcomplicating the storage layer for simple needs
- Missing error handling for storage failures
See Also
Section titled “See Also”- SharedPreferences Documentation – Flutter’s key-value storage solution
- Hive Documentation – Fast, lightweight NoSQL database for Flutter
- Drift Documentation – Type-safe SQLite wrapper for Flutter
- SQFlite Documentation – SQLite plugin for Flutter
- State Management – Related guide on state management