Skip to content
FlutterLearn

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.

DataUsePackage
Settings, flags, last-opened tabKey–value preferencesshared_preferences
Images, PDFs, exports, cached payloadsFiles on diskpath_provider + dart:io
Lists you filter, sort, paginate or relateSQLitesqflite or drift
Tokens, passwords, keysEncrypted platform storageflutter_secure_storage

Key–value settings

lib/data/settings_store.dart
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

Dart
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.

lib/data/task_database.dart
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.

Dart
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.

Dart
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 onUpgrade migration.
  • 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.