Intermediate · Lesson 4 · 12 min read
Storing Data Locally
Pick the right storage for the job: shared_preferences for settings, files for documents, SQLite for structured data, secure storage for secrets.
Updated July 28, 2026
What you will learn
- Choose between preferences, files, SQLite and secure storage
- Read and write key–value settings
- Model and query relational data with sqflite
- Design a simple offline-first caching strategy
"Where do I save this?" has four common answers in Flutter, and picking wrong costs you a migration later. Match the storage to the shape and sensitivity of the data.
| Data | Use | Package |
|---|---|---|
| Settings, flags, last-opened tab | Key–value preferences | shared_preferences |
| Images, PDFs, exports, cached payloads | Files on disk | path_provider + dart:io |
| Lists you filter, sort, paginate or relate | SQLite | sqflite or drift |
| Tokens, passwords, keys | Encrypted platform storage | flutter_secure_storage |
Key–value settings
import 'package:shared_preferences/shared_preferences.dart';
class SettingsStore {
SettingsStore(this._prefs);
final SharedPreferences _prefs;
static Future<SettingsStore> create() async =>
SettingsStore(await SharedPreferences.getInstance());
static const _themeKey = 'theme_mode';
static const _onboardedKey = 'has_onboarded';
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);
bool get hasOnboarded => _prefs.getBool(_onboardedKey) ?? false;
Future<void> setOnboarded() => _prefs.setBool(_onboardedKey, true);
}Wrapping preferences in a class rather than sprinkling getString('theme_mode') across the app means keys exist in exactly one place, types are enforced, and you can swap the implementation in tests.
Files on disk
import 'dart:io';
import 'package:path_provider/path_provider.dart';
Future<File> _localFile(String name) async {
// Backed up, survives updates — for user documents
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/$name');
}
Future<void> saveDraft(String text) async {
final file = await _localFile('draft.txt');
await file.writeAsString(text, flush: true);
}
Future<String?> readDraft() async {
try {
final file = await _localFile('draft.txt');
if (!await file.exists()) return null;
return await file.readAsString();
} on FileSystemException {
return null;
}
}getApplicationDocumentsDirectory()— user data you must not lose. Backed up by the OS.getApplicationSupportDirectory()— app-managed data the user never sees directly.getTemporaryDirectory()— caches. The OS may delete this at any time, so always be able to regenerate it.
SQLite with sqflite
When you need to query — "unfinished tasks due this week, sorted by priority" — use a database. Loading a JSON file and filtering it in Dart stops working once the list is large.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
class TaskDatabase {
Database? _db;
Future<Database> get database async => _db ??= await _open();
Future<Database> _open() async {
final dbPath = p.join(await getDatabasesPath(), 'tasks.db');
return openDatabase(
dbPath,
version: 2,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
due_at INTEGER,
priority INTEGER NOT NULL DEFAULT 0
)
''');
await db.execute('CREATE INDEX idx_tasks_due ON tasks(due_at)');
},
onUpgrade: (db, oldVersion, newVersion) async {
// Migrations are additive and run in order
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0',
);
}
},
);
}
Future<int> insert(Task task) async {
final db = await database;
return db.insert('tasks', task.toRow(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<List<Task>> dueThisWeek() async {
final db = await database;
final weekEnd = DateTime.now().add(const Duration(days: 7));
final rows = await db.query(
'tasks',
where: 'done = 0 AND due_at <= ?',
whereArgs: [weekEnd.millisecondsSinceEpoch],
orderBy: 'priority DESC, due_at ASC',
);
return rows.map(Task.fromRow).toList();
}
Future<void> setDone(int id, bool done) async {
final db = await database;
await db.update('tasks', {'done': done ? 1 : 0},
where: 'id = ?', whereArgs: [id]);
}
}SQLite stores no boolean or date type: use INTEGER with 0/1 for booleans and epoch milliseconds for timestamps, and convert at the model boundary.
class Task {
const Task({required this.id, required this.title, required this.done, this.dueAt});
final int? id;
final String title;
final bool done;
final DateTime? dueAt;
Map<String, Object?> toRow() => {
if (id != null) 'id': id,
'title': title,
'done': done ? 1 : 0,
'due_at': dueAt?.millisecondsSinceEpoch,
};
static Task fromRow(Map<String, Object?> row) => Task(
id: row['id'] as int?,
title: row['title'] as String,
done: (row['done'] as int) == 1,
dueAt: row['due_at'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(row['due_at'] as int),
);
}Offline-first caching
A dependable pattern: read from cache immediately so the UI is never blank, refresh from the network in the background, then update the cache and the UI.
Stream<List<Article>> watchArticles() async* {
// 1. Show whatever we already have, instantly
final cached = await _db.allArticles();
if (cached.isNotEmpty) yield cached;
// 2. Refresh in the background
try {
final fresh = await _api.fetchArticles();
await _db.replaceAll(fresh);
yield fresh;
} on ApiException {
// Offline with a cache is fine; offline with nothing is an error
if (cached.isEmpty) rethrow;
}
}Store a fetched_at timestamp alongside cached rows so you can decide whether the cache is fresh enough to skip the network entirely, and so you can show "last updated 5 minutes ago" in the UI.
Key takeaways
- Preferences for settings, files for documents, SQLite for queryable data, secure storage for secrets.
- Wrap storage behind a class so keys, types and migrations live in one place.
- Every schema change needs a version bump and an
onUpgrademigration. - Offline-first means: render cache first, refresh in the background, tolerate network failure.
Practice
Offline-capable reading list
Build a reading list that fetches articles from an API, stores them in sqflite with a fetched_at column, and renders instantly from cache on launch. Add a bookmark flag saved locally, a theme preference in shared_preferences, and a visible 'last updated' label.
Show hints
- Bookmarks are local-only — do not let the network refresh overwrite them.
- Test the offline path by enabling airplane mode after a first successful load.