Module 14: Local Storage
Teach: How to persist data locally using SharedPreferences for simple settings and SQLite for structured data. See: A settings screen backed by SharedPreferences and a notes app backed by SQLite, both with full CRUD operations. Feel: That local persistence is two tools with clear use cases -- not a confusing landscape of options.
Two Tools, Two Jobs
Teach: The two main local storage options in Flutter and the simple decision tree for choosing between them. See: SharedPreferences as a sticky note (simple key-value) vs SQLite as a filing cabinet (structured data). Feel: That the choice is clear-cut -- not a confusing landscape of competing options.
Your app has a memory problem. Everything you have built so far -- the shopping cart, the posts list, the theme toggle -- vanishes the moment the user closes the app. All that data lives in RAM, and RAM is fleeting. To make data survive between app launches, you need to write it to the device's storage. Flutter gives you two main tools for this, and choosing between them is simple. Got a few settings, a username, a boolean flag? SharedPreferences. Got structured data with multiple fields and records -- notes, tasks, contacts? SQLite. That is the entire decision tree.
Think of it this way: - SharedPreferences is a sticky note on your fridge. Quick to write, quick to read, holds a few simple things. - SQLite is a filing cabinet. Organized, searchable, handles thousands of records with structure and relationships.
SharedPreferences: Key-Value Storage
Teach: How to use SharedPreferences to save, load, remove, and clear simple key-value settings. See: Basic CRUD operations on bools, doubles, and strings, wrapped in a clean SettingsService class. Feel: That persisting simple settings is almost boringly easy -- and that is a good thing.
SharedPreferences stores data as key-value pairs. Keys are strings. Values can be strings, ints, doubles, bools, or string lists. That is it. No nesting, no objects, no queries.
Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.2.0
Run flutter pub get.
Basic Operations
import 'package:shared_preferences/shared_preferences.dart';
// Save values
Future<void> saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('darkMode', true);
await prefs.setDouble('fontSize', 18.0);
await prefs.setString('username', 'Campbell');
}
// Read values (with defaults)
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final darkMode = prefs.getBool('darkMode') ?? false;
final fontSize = prefs.getDouble('fontSize') ?? 16.0;
final username = prefs.getString('username') ?? 'User';
}
// Remove a single key
Future<void> clearUsername() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('username');
}
// Check if a key exists
Future<bool> hasUsername() async {
final prefs = await SharedPreferences.getInstance();
return prefs.containsKey('username');
}
// Remove ALL preferences
Future<void> clearAll() async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
}
Notice the pattern. Every operation starts with await SharedPreferences.getInstance(). That gives you the singleton instance. Then you call set, get, or remove. The getter methods return null if the key does not exist, so always provide a default with the null-coalescing operator. This is not rocket science, and that is the point. SharedPreferences is supposed to be boring and reliable.
A SettingsService Class
Wrap SharedPreferences in a service class to keep your UI code clean:
class SettingsService {
Future<void> setDarkMode(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('darkMode', value);
}
Future<bool> getDarkMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool('darkMode') ?? false;
}
Future<void> setFontSize(double value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('fontSize', value);
}
Future<double> getFontSize() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getDouble('fontSize') ?? 16.0;
}
Future<void> setUsername(String name) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('username', name);
}
Future<String> getUsername() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('username') ?? 'User';
}
Future<void> clearAllSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
}
}
SharedPreferences is for simple key-value data: user settings, flags, and small preferences. If you are thinking about storing a list of objects, you need SQLite.
SQLite: A Real Database on the Device
Teach: How to set up SQLite with sqflite, create tables, and perform CRUD operations using the database helper pattern. See: A complete NoteDatabase class that creates, reads, updates, and deletes notes. Feel: That SQLite in Flutter follows the same patterns as any other SQL database -- just with Dart syntax.
When your data has structure -- multiple fields, multiple records, the need to search or sort -- SQLite is the right tool. It gives you a full relational database running locally on the device.
Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
sqflite: ^2.3.0
path: ^1.8.0
Run flutter pub get.
The Database Helper Pattern
Every Flutter SQLite app follows the same structure: a helper class that manages the database connection and exposes CRUD methods.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class NoteDatabase {
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'notes.db');
return await openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
createdAt TEXT NOT NULL
)
''');
},
);
}
}
Let's walk through this. The _database field is a static nullable variable -- it starts as null. The database getter checks: "Do I already have a database? If yes, return it. If no, create one." This is the singleton pattern. You only open the database once, no matter how many times you access it. The _initDatabase method finds the right directory on the device, creates the database file, and runs the onCreate callback to set up your tables. That callback only runs the very first time the database is created.
The onCreate callback uses raw SQL. If you have never written SQL, the CREATE TABLE statement defines the shape of your data:
- id auto-increments so each note gets a unique number.
- TEXT NOT NULL means a required string field.
- createdAt is stored as an ISO 8601 string because SQLite does not have a native DateTime type.
Model Classes for the Database
Teach: How to create model classes with toMap, fromMap, and copyWith for clean database interaction. See: A Note model that converts between Dart objects and database maps, with nullable id for new records. Feel: That the model class pattern from APIs applies directly to databases -- same idea, different format.
Just like you created model classes for JSON APIs, create them for database records:
class Note {
final int? id;
final String title;
final String content;
final DateTime createdAt;
Note({
this.id,
required this.title,
required this.content,
required this.createdAt,
});
// Convert Note to a Map for database insertion
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'content': content,
'createdAt': createdAt.toIso8601String(),
};
}
// Create a Note from a database row
factory Note.fromMap(Map<String, dynamic> map) {
return Note(
id: map['id'],
title: map['title'],
content: map['content'],
createdAt: DateTime.parse(map['createdAt']),
);
}
// Create a copy with some fields changed
Note copyWith({
int? id,
String? title,
String? content,
DateTime? createdAt,
}) {
return Note(
id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content,
createdAt: createdAt ?? this.createdAt,
);
}
}
Notice that id is nullable (int?). When you create a new note, you do not know its ID yet -- the database assigns it. Once it is saved, id has a value.
The copyWith method is essential for updates. Instead of mutating a note, you create a new one with the changed fields.
The copyWith pattern feels weird until you use it a few times. Why not just mutate the fields? Because immutable models are safer. If you hand a Note to three widgets and one of them mutates it, the other two see the change without knowing — spooky action at a distance. With immutable models and copyWith, you always produce a new object, make it explicit that something changed, and the old object is untouched. The DateTime-to-ISO-string conversion in toMap is also worth noting. SQLite doesn't have a native date type, so we store ISO strings and parse them back. Dart's DateTime.parse handles ISO 8601 without fuss.
CRUD Operations
Teach: How to implement Create, Read, Update, and Delete operations using sqflite's query methods. See: Four concise methods -- insert, query, update, delete -- each following the same get-db-then-call rhythm. Feel: That CRUD with SQLite is a consistent pattern, not four separate things to memorize.
With the database helper and model class in place, add the four CRUD operations:
Create
Future<int> insertNote(Note note) async {
final db = await database;
return await db.insert('notes', note.toMap());
}
db.insert returns the auto-generated ID of the new row.
Read All
Future<List<Note>> getAllNotes() async {
final db = await database;
final maps = await db.query('notes', orderBy: 'createdAt DESC');
return maps.map((map) => Note.fromMap(map)).toList();
}
db.query returns a List<Map<String, dynamic>>. The orderBy parameter sorts by creation date, newest first.
Read One
Future<Note?> getNote(int id) async {
final db = await database;
final maps = await db.query(
'notes',
where: 'id = ?',
whereArgs: [id],
);
if (maps.isNotEmpty) {
return Note.fromMap(maps.first);
}
return null;
}
The where and whereArgs pattern prevents SQL injection. Never concatenate user input directly into SQL strings.
Update
Future<int> updateNote(Note note) async {
final db = await database;
return await db.update(
'notes',
note.toMap(),
where: 'id = ?',
whereArgs: [note.id],
);
}
Delete
Future<int> deleteNote(int id) async {
final db = await database;
return await db.delete(
'notes',
where: 'id = ?',
whereArgs: [id],
);
}
All four operations follow the same rhythm. Get the database instance. Call the appropriate method. Use where and whereArgs for filtering. Convert between Note objects and Maps at the boundary. The database helper handles the SQL; your UI code only sees Note objects going in and coming out.
Always use whereArgs instead of string interpolation in SQL queries. This prevents SQL injection attacks and handles special characters correctly.
When to Use Which
Teach: A clear comparison of SharedPreferences vs SQLite across data shape, volume, querying, and use cases. See: A decision table that makes the choice obvious for any given storage need. Feel: That you will never be confused about which storage tool to reach for.
| Feature | SharedPreferences | SQLite |
|---|---|---|
| Data shape | Key-value pairs | Tables with rows and columns |
| Data types | String, int, double, bool, List\<String> | Any SQL type |
| Querying | Get by key only | WHERE, ORDER BY, JOIN, etc. |
| Volume | Handfuls of values | Thousands of records |
| Use cases | Settings, flags, tokens | Notes, contacts, cache, offline data |
| Setup complexity | Minimal | Moderate (schema, helpers) |
If you find yourself wanting to store a list of maps in SharedPreferences by serializing to JSON, stop. That is a sign you need SQLite.
This table is your decision-making cheat sheet for life. A handful of scalar values? SharedPreferences. Anything with structure, relationships, or the potential to grow past a few hundred records? SQLite. I want to call out the "serializing to JSON" anti-pattern specifically — I've seen dozens of junior developers reach for SharedPreferences to store a list of tasks or contacts by JSON-encoding a list. It works at ten records. It gets slow at a hundred. It becomes a nightmare at a thousand. Databases exist for a reason. If you find yourself writing prefs.setString('tasks', jsonEncode(tasks)), that's your signal to create a proper SQLite table.
There Are No Dumb Questions
Teach: Answers to common local storage questions about encryption, schema migration, singletons, and alternative packages. See: Clear guidance on security, database versioning, and when to consider drift, Hive, or Isar. Feel: That the practical concerns around local storage have straightforward solutions.
Q: Does SharedPreferences encrypt data?
A: No. It stores data in plain text in an XML file (Android) or plist (iOS). Never store passwords, API keys, or sensitive data in SharedPreferences. For secrets, use the flutter_secure_storage package, which uses the device's keychain/keystore.
Q: What happens to the SQLite database when the user uninstalls the app?
A: It is deleted. App storage is tied to the app lifecycle. If the user reinstalls, the database starts fresh.
Q: Can I have multiple tables in one SQLite database?
A: Absolutely. Just add more CREATE TABLE statements in the onCreate callback. Most real apps have several tables.
Q: What if I need to change the database schema later?
A: Increment the version number in openDatabase and provide an onUpgrade callback. That callback receives the old and new version numbers so you can run ALTER TABLE statements to migrate data.
Q: Should I create a new DatabaseHelper instance every time I need it?
A: You can, because the database itself is a singleton (stored in the static _database variable). But a cleaner approach is to make the helper class a singleton too, or use Provider to provide a single instance.
Q: Is sqflite the only option for local databases?
A: No. drift (formerly moor) is a popular alternative that provides a type-safe, reactive query API on top of SQLite. Hive and Isar are NoSQL alternatives. For learning, sqflite teaches you raw SQL skills that transfer everywhere.
The encryption question is important enough to call out. SharedPreferences writes data in plain text — if an attacker has the device, they can read it. For passwords, API keys, session tokens, and anything that would be embarrassing or damaging in the wrong hands, use flutter_secure_storage instead. It stores values in iOS Keychain or Android Keystore, which are hardware-backed on modern devices. I mention this because it's a common oversight — "I'll just put the API key in SharedPreferences for now" often becomes production code without anyone noticing. Security is a habit. Build it early.
Sharpen Your Pencil: Settings Screen
Teach: How to build a settings screen that persists user preferences with SharedPreferences. See: A screen with a switch, slider, and text field that all save and restore their values across app restarts. Feel: That wiring up persistent settings is a practical skill you can use immediately.
Build a settings screen backed by SharedPreferences.
SettingsService (settings_service.dart)
- Methods:
setDarkMode(bool),getDarkMode()(default false),setFontSize(double),getFontSize()(default 16.0),setUsername(String),getUsername()(default 'User'),clearAllSettings(). - All methods use
SharedPreferences.getInstance().
SettingsScreen (settings_screen.dart)
StatefulWidgetthat loads saved values ininitState.- A
Switchfor dark mode, wired to save on change. - A
Sliderfor font size (range 12.0 to 24.0), wired to save on change. - A
TextFieldfor username, wired to save on change. - A "Reset Settings" button that calls
clearAllSettings()and resets the UI to defaults.
Notice how this exercise separates the SettingsService from the SettingsScreen. The service knows about SharedPreferences. The screen knows about widgets. Neither knows about the other's internals. That split pays off the moment you want to change something — swap SharedPreferences for secure storage, or add a migration step, and only the service changes. The screen stays identical. This layered design scales all the way up to enterprise apps. Start practicing it on small exercises like this so it feels natural when you're building something serious.
Sharpen Your Pencil: Notes App with SQLite
Teach: How to build a complete CRUD app backed by SQLite with a database helper, model class, and list UI. See: A notes app where you can create, read, edit, and delete notes that persist in a local database. Feel: That building a database-backed app is assembling familiar pieces -- model, helper, UI -- not learning something alien.
Build a full notes application with CRUD operations.
Note Model (note_model.dart)
Noteclass withid(int?, nullable),title(String),content(String),createdAt(DateTime).toMap()method (store createdAt as ISO 8601 string).factory Note.fromMap(Map<String, dynamic>)constructor.copyWith()method for immutable updates.
NoteDatabase (note_database.dart)
- Singleton pattern with static
_databasefield. onCreatecreates anotestable with id, title, content, createdAt columns.insertNote(Note)-- returns new id.getAllNotes()-- returnsList<Note>ordered by createdAt descending.updateNote(Note)-- updates by id.deleteNote(int id)-- deletes by id.
NotesScreen (notes_screen.dart)
StatefulWidgetthat loads notes on init and after every operation.ListViewdisplaying each note's title and creation date.FloatingActionButtonopens a dialog to add a note (title and content fields).- Tapping a note opens an edit dialog with pre-filled fields.
- Swipe-to-delete or delete icon on each note.
- The list refreshes after every create, update, or delete.
Main App (main.dart)
BottomNavigationBarwith two tabs: "Notes" and "Settings".- Notes tab shows
NotesScreen. - Settings tab shows
SettingsScreen. - The dark mode setting controls the app's theme (read the preference in the top-level widget and switch
ThemeDataaccordingly).
This exercise is the capstone of the module. You're building a real app that persists two kinds of data in two different stores. Notice the order: note model, then database helper, then screen. Build from the data outward. The reason the notes screen reloads the list after every operation is that sqflite doesn't push changes to listeners — you have to re-query. In a real app you'd probably combine this with a ChangeNotifier so the UI reacts to data changes without manual reloads, but for this exercise, manual refresh is the clearer pedagogical path. Focus on getting CRUD solid first.
Sharpen Your Pencil: Putting It Together
Teach: How to combine SharedPreferences and SQLite in the same app with a tabbed interface. See: A main.dart that wires Notes and Settings together, with the dark mode preference controlling the theme. Feel: That local storage completes the picture -- your app now remembers things between launches.
Wire the Notes and Settings screens into a single app:
- The top-level widget reads the dark mode preference on startup.
- It passes the appropriate
ThemeDatatoMaterialApp. - When the user toggles dark mode in Settings, the theme updates immediately.
- Notes persist across app restarts because they live in SQLite.
- Settings persist because they live in SharedPreferences.
Close the app. Reopen it. Your notes are still there. Your dark mode setting is still on. This is the moment your apps graduate from "demos" to "real things people could actually use." The combination of SharedPreferences and SQLite in one cohesive app is what every note-taking, task-tracking, or journaling app does under the hood. With navigation from Module 10, theming from Module 11, state management from Module 12, and now persistence, you have the entire foundation for a shippable Flutter application. The remaining modules add polish — custom widgets, animations, testing — on top of this base.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
Data That Survives
Teach: A recap of the two local storage tools and how they fit into the full Flutter development toolkit. See: The complete persistence story -- SharedPreferences for settings, SQLite for structured data -- in context. Feel: That your apps are now complete -- they can navigate, theme, manage state, fetch data, and remember things.
Before this module, your apps had amnesia. Close them, and everything was forgotten. Now you have two tools to fix that. SharedPreferences for the small stuff -- a toggle here, a username there. SQLite for the structured stuff -- notes, records, anything with rows and columns. Together they cover about 90% of local storage needs in a mobile app. The remaining 10% is files, encrypted storage, and exotic databases -- but those are stories for another day.
Where this fits: Local storage is the last major building block before you start building complete, production-quality Flutter applications. You now have navigation, theming, state management, networking, and persistence. In the upcoming modules, you will learn to create custom widgets, add animations, and pull it all together in a capstone project.