SQLite with sqflite: Database Setup and Schema Design
SQLite with sqflite: Database Setup and Schema Design
When an app needs to persist structured, relational data locally — such as user-created notes, cached API responses, or offline-capable records — a full SQL database is the right tool. sqflite is the most widely used Flutter plugin for SQLite, the self-contained, serverless relational database engine that runs directly on the device. This lesson covers how to open a sqflite database, define a versioned schema with onCreate, write proper DDL statements, and wrap everything in a thread-safe singleton helper.
Adding sqflite to Your Project
Add the two required packages to pubspec.yaml:
dependencies:
sqflite: ^2.3.3 # SQLite bindings for Flutter
path: ^1.9.0 # Cross-platform path utilities
Run flutter pub get to install them. The path package is needed to build the absolute file path where SQLite stores the .db file on the device.
Opening a Database with openDatabase
The core API call is openDatabase(path, version: n, onCreate: callback). sqflite locates (or creates) the file, runs any pending migration callbacks, and returns a Database handle wrapped in a Future. The database file is stored in the app’s documents directory, which is private to the app and persists across app restarts.
getDatabasesPath() from sqflite combined with join() from the path package to build the file path. Hard-coding an absolute path or using a relative path will fail on different devices and operating systems.import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
Future<Database> openMyDatabase() async {
// Resolve the platform-specific databases directory
final dbPath = await getDatabasesPath();
// Join produces e.g. /data/user/0/com.example.app/databases/app.db
final path = join(dbPath, 'app.db');
return openDatabase(
path,
version: 1,
onCreate: (Database db, int version) async {
// Called ONLY when the database file does not exist yet
await db.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
color INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
''');
},
);
}
Defining a Versioned Schema with onCreate
The onCreate callback receives the fresh Database and the target version integer. Inside it, you execute one or more CREATE TABLE statements using db.execute(). Key DDL considerations:
- INTEGER PRIMARY KEY AUTOINCREMENT — SQLite auto-assigns a unique, ever-increasing rowid to each inserted row.
- NOT NULL constraints — enforce data integrity at the database level, not just in Dart code.
- DEFAULT values — guarantee a sensible fallback when optional columns are omitted during insertion.
- INTEGER for booleans and timestamps — SQLite has no native bool or datetime type; store booleans as
0/1and timestamps as Unix epoch milliseconds. - FOREIGN KEY constraints — link related tables; must be enabled per-connection with
PRAGMA foreign_keys = ON.
'''...''') for SQL statements. They are easier to read, format, and diff than concatenated single-line strings.Building a Database Helper Singleton
Opening a database is an expensive async operation. The recommended pattern is a singleton: a single class instance holds the Database handle and exposes it through a lazy-initialised getter. All parts of the app share this one connection, avoiding duplicate file handles and race conditions during startup.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
// Private constructor prevents external instantiation
DatabaseHelper._internal();
// The single instance, stored as a static field
static final DatabaseHelper instance = DatabaseHelper._internal();
// Nullable backing field; null means "not yet opened"
Database? _database;
// Public getter — opens the DB on first call, returns cached handle after
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'notes_app.db');
return openDatabase(
path,
version: 2,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
// Enable foreign-key enforcement for every connection
onConfigure: (db) async => await db.execute('PRAGMA foreign_keys = ON'),
);
}
Future<void> _onCreate(Database db, int version) async {
// Use a Batch for multiple DDL statements — runs them atomically
final batch = db.batch();
batch.execute('''
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
''');
batch.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
// Speeds up lookups that filter or sort by category
batch.execute(
'CREATE INDEX idx_notes_category ON notes(category_id)',
);
await batch.commit(noResult: true);
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
// Schema migration: add a column introduced in version 2
await db.execute(
'ALTER TABLE notes ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0',
);
}
}
}
Using the Singleton in Practice
Any part of the app accesses the database through the shared instance:
// Inserting a row
Future<int> insertNote(Map<String, dynamic> note) async {
final db = await DatabaseHelper.instance.database;
return db.insert('notes', note, conflictAlgorithm: ConflictAlgorithm.replace);
}
// Querying all rows
Future<List<Map<String, dynamic>>> getAllNotes() async {
final db = await DatabaseHelper.instance.database;
return db.query('notes', orderBy: 'created_at DESC');
}
Schema Design Best Practices
- Use plural snake_case table names (
notes,categories) and singular snake_case column names. - Store all timestamps as Unix epoch milliseconds (
DateTime.now().millisecondsSinceEpoch) for reliable sorting and cross-platform consistency. - Avoid storing JSON blobs in TEXT columns for data you need to filter or join — normalise into separate tables instead.
- Always increment the version number and implement
onUpgradebefore shipping schema changes; never alter the schema insideonCreatedirectly for an existing version.
openDatabase directly inside a widget’s build() method or inside a FutureBuilder without caching the result. Every call to build() would re-open the database, producing multiple file handles and eventually causing DatabaseException: database is locked errors.Summary
The sqflite workflow for structured local data starts with openDatabase(path, version, onCreate). The onCreate callback runs DDL statements that define tables, constraints, and indexes. Wrapping this in a singleton DatabaseHelper with a lazy-initialised database getter ensures the expensive open operation happens only once and that all queries share a single connection. Version numbers and the onUpgrade callback give you a safe, incremental path for evolving the schema over time without losing user data.