Local Data Storage

Drift ORM: Type-Safe Database with Code Generation

16 min Lesson 10 of 12

Drift ORM: Type-Safe Database with Code Generation

Drift (formerly known as Moor) is a reactive, type-safe persistence library for Flutter and Dart. Instead of writing raw SQL and manually mapping rows to Dart objects, Drift lets you define your database schema as ordinary Dart classes. A code generator then produces a fully-typed database layer, DAOs, and query helpers — eliminating an entire class of runtime errors caused by typos or type mismatches in SQL strings.

Why Drift over plain sqflite? With sqflite you write raw SQL strings, cast Map<String, dynamic> results by hand, and discover errors only at runtime. Drift surfaces those errors at compile time: a misspelled column or a wrong return type is a build error, not a crash in production.

Setting Up Drift

Add the following to your pubspec.yaml:

pubspec.yaml dependencies

dependencies:
  drift: ^2.14.0
  drift_flutter: ^0.2.0   # provides the Flutter-specific database opener
  sqlite3_flutter_libs: ^0.5.0

dev_dependencies:
  drift_dev: ^2.14.0
  build_runner: ^2.4.0

Run flutter pub get after editing the file.

Defining Tables as Dart Classes

Every table is a Dart class that extends Table. Each getter you declare becomes a column in the generated SQL schema. Drift infers the SQL type from the Dart type you use:

lib/database/tables.dart — defining a Tasks table

import 'package:drift/drift.dart';

// Each class = one SQL table
class Tasks extends Table {
  // INTEGER PRIMARY KEY AUTOINCREMENT
  IntColumn get id => integer().autoIncrement()();

  // TEXT NOT NULL
  TextColumn get title => text().withLength(min: 1, max: 200)();

  // TEXT (nullable)
  TextColumn get description => text().nullable()();

  // INTEGER (0 or 1, stored as boolean)
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();

  // INTEGER representing a Unix timestamp
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

class Categories extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 100)();
  // Foreign key to tasks (Drift validates at the Dart level)
  IntColumn get taskId => integer().references(Tasks, #id)();
}

Creating the Database Class and Running build_runner

You wire the tables together in a single class annotated with @DriftDatabase. The part directive tells Dart to expect a generated file alongside yours:

lib/database/app_database.dart — database class

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'tables.dart';

part 'app_database.g.dart'; // generated by build_runner

@DriftDatabase(tables: [Tasks, Categories])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  // Schema version — increment when you add/change tables
  @override
  int get schemaVersion => 1;

  static QueryExecutor _openConnection() {
    return driftDatabase(name: 'app_db');
  }
}

Generate the boilerplate by running:

Terminal — generate the database layer

flutter pub run build_runner build --delete-conflicting-outputs

This creates app_database.g.dart containing the _$AppDatabase mixin, typed row classes (Task, Category), and companion insert/update classes (TasksCompanion, CategoriesCompanion).

Tip: During active development use build_runner watch instead of build. It regenerates the file automatically each time you save a change to your table definitions, so you always have an up-to-date generated file without running the command manually.

Type-Safe CRUD with DAOs

A DAO (Data Access Object) groups related queries into one class and keeps your database logic out of widgets. Annotate it with @DriftAccessor:

lib/database/task_dao.dart — DAO with full CRUD

import 'package:drift/drift.dart';
import 'app_database.dart';
import 'tables.dart';

part 'task_dao.g.dart';

@DriftAccessor(tables: [Tasks])
class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
  TaskDao(super.db);

  // SELECT * FROM tasks ORDER BY created_at DESC
  Stream<List<Task>> watchAllTasks() =>
      (select(tasks)..orderBy([(t) => OrderingTerm.desc(t.createdAt)])).watch();

  // SELECT * FROM tasks WHERE is_completed = 0
  Future<List<Task>> getIncompleteTasks() =>
      (select(tasks)..where((t) => t.isCompleted.equals(false))).get();

  // INSERT — uses a companion to safely supply only the columns you know
  Future<int> insertTask(TasksCompanion entry) => into(tasks).insert(entry);

  // UPDATE — only the columns present in the companion are changed
  Future<bool> updateTask(TasksCompanion entry) => update(tasks).replace(entry);

  // DELETE by primary key
  Future<int> deleteTask(int id) =>
      (delete(tasks)..where((t) => t.id.equals(id))).go();
}

In your widget you consume the DAO reactively using StreamBuilder:

Widget — watching tasks with StreamBuilder

class TaskListPage extends StatelessWidget {
  final TaskDao dao;
  const TaskListPage({super.key, required this.dao});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<Task>>(
      stream: dao.watchAllTasks(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const CircularProgressIndicator();
        final tasks = snapshot.data!;
        return ListView.builder(
          itemCount: tasks.length,
          itemBuilder: (context, index) {
            final task = tasks[index];
            return ListTile(
              title: Text(task.title),
              subtitle: Text(task.description ?? ''),
              trailing: Checkbox(
                value: task.isCompleted,
                onChanged: (_) => dao.updateTask(
                  TasksCompanion(
                    id: Value(task.id),
                    isCompleted: Value(!task.isCompleted),
                  ),
                ),
              ),
              onLongPress: () => dao.deleteTask(task.id),
            );
          },
        );
      },
    );
  }
}
Warning: Never forget to re-run build_runner after modifying a table or DAO. If the generated file is out of date, you will see confusing compile errors about missing members. When in doubt, delete the .g.dart files and run build_runner build --delete-conflicting-outputs from scratch.

Migrations — Handling Schema Changes

When you add a column or table, increment schemaVersion and override migration in your database class:

Adding a priority column in version 2

@override
int get schemaVersion => 2;

@override
MigrationStrategy get migration => MigrationStrategy(
  onUpgrade: (migrator, from, to) async {
    if (from < 2) {
      // Add the new column; existing rows default to 0
      await migrator.addColumn(tasks, tasks.priority);
    }
  },
);

Summary

Drift transforms SQLite database access in Flutter from error-prone raw SQL into a fully compile-time-checked experience. You define tables as Dart classes, run build_runner once to generate the database and DAO boilerplate, and then perform CRUD using strongly-typed methods and companion objects. Reactive streams (via watch()) let your UI rebuild automatically whenever the underlying data changes — making Drift the most robust choice for complex local data requirements.