Flutter Drift Tutorial: Type-Safe SQLite for Dart

Coding Liquids blog cover featuring Sagnik Bhattacharya for Flutter Drift Tutorial: Type-Safe SQLite for Dart, with drift and build_runner setup, a table, generated database, and dao boundary, and type-safe selects, inserts, and updates.
Coding Liquids blog cover featuring Sagnik Bhattacharya for Flutter Drift Tutorial: Type-Safe SQLite for Dart, with drift and build_runner setup, a table, generated database, and dao boundary, and type-safe selects, inserts, and updates.

Video makes a watched query emission easier to follow than a static timeline.

Subscribe on YouTube@codingliquids

Choose Drift over raw sqflite deliberately

Drift builds a typed Dart query layer over SQLite and can watch query results reactively. It pays off when a project values generated row classes, composable queries, and migration tooling.

A tiny one-table prototype may not justify code generation and additional abstractions.

Split runtime and generator dependencies

drift and drift_flutter belong in dependencies, while drift_dev and build_runner belong in dev_dependencies. Run dart run build_runner build after schema changes and never import the generator packages from application code.

Putting drift_dev in the shipped dependency graph increases noise and signals a broken setup.

flutter pub add drift drift_flutter
flutter pub add --dev drift_dev build_runner

Instagram query diagrams show how Drift carries schema types into generated selects and joins.

Follow me on Instagram@sagnikteaches

Describe columns in a Table class

A Drift Table declares typed columns, constraints, defaults, lengths, and auto-increment keys in Dart. Stable table and column names matter because generated getters map onto the persisted SQLite schema.

Renaming a Dart getter without a migration can make Drift expect a column that existing databases lack.

The Complete Flutter Guide course thumbnail

Build production-ready Drift Tutorial features

The Complete Flutter Guide turns drift tutorial into maintainable app architecture, polished UI, and testable production code.

Enrol now

Declare the generated database and version

Annotate the database with DriftDatabase, include part app_database.g.dart, and extend the generated superclass. schemaVersion must increase whenever a migration changes persisted structure.

Forgetting to rerun build_runner leaves generated code out of sync with the source table.

dependencies:
  drift: any
  drift_flutter: any

dev_dependencies:
  drift_dev: any
  build_runner: any

Write type-safe inserts and selects

into(todos).insert accepts a TodosCompanion, and select(todos).get returns generated Todo rows. Use Value.absent for omitted auto-generated columns and Value for explicit nullable updates.

Raw maps and string column names discard the compiler checks that justify choosing Drift.

A LinkedIn deep dive covers migration tests, stream invalidation, and transactions that match business actions.

Connect on LinkedInSagnik Bhattacharya
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Stream query changes into the widget tree

Calling watch on a select statement returns a stream that emits after relevant writes. StreamBuilder can render the generated Todo list without manually refetching after insert or update.

Creating a fresh watched query in rapidly rebuilding widgets can cause unnecessary subscriptions.

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

part 'app_database.g.dart';

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 120)();
  BoolColumn get done => boolean().withDefault(const Constant(false))();
  IntColumn get priority => integer().withDefault(const Constant(0))();
}

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(driftDatabase(name: 'todos'));
  @override
  int get schemaVersion => 2;

  Future<int> addTodo(String title) =>
      into(todos).insert(TodosCompanion.insert(title: title));
  Future<List<Todo>> pendingTodos() =>
      (select(todos)..where((row) => row.done.equals(false))).get();
  Stream<List<Todo>> watchPendingTodos() =>
      (select(todos)..where((row) => row.done.equals(false))).watch();

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (m) => m.createAll(),
    onUpgrade: (m, from, to) async {
      if (from < 2) await m.addColumn(todos, todos.priority);
    },
  );
}

Migrate existing databases with MigrationStrategy

migration.onUpgrade receives the old and new versions and a Migrator for adding columns or tables. Enable foreign keys before opening where relationships rely on them and test every supported upgrade path.

A destructive delete-and-recreate strategy loses user data and should never masquerade as a migration.

A useful Drift verification is to insert one pending row, subscribe to watchPendingTodos, then update that row through a generated companion. The stream should emit the inserted row and then remove it from the pending query when done becomes true. This checks generated mapping, the WHERE expression, write notifications, and StreamBuilder integration together.

Use Drift as a typed data boundary

Drift is most valuable when query definitions live beside the schema instead of leaking SQL details into screens. Return domain models or narrowly shaped result objects from a repository, and expose a stream only where live updates are genuinely useful. A broad watch query can rebuild an expensive subtree for an unrelated row change. Selecting the required columns and adding a precise filter keeps both generated mapping and invalidation work smaller.

Schema versions must advance with every structural change that reaches production. In the migration strategy, create new tables and columns in version order, backfill values where a non-null constraint requires them, and verify the result before dropping old data. Users can skip releases, so test direct upgrades from each supported historical version to the current one. Drift’s schema export and migration tooling can catch an accidental destructive change before a device encounters it.

Transactions should match business actions rather than individual helper methods. Creating an invoice and its lines, for example, needs one transaction so observers never receive a header without its children. Keep slow network work outside the transaction; otherwise the database remains locked while the app waits on an unrelated service. For conflict handling, decide whether an insert should fail, replace, or update selected columns, and make that choice explicit at the query site.

Run database work on the platform configuration recommended for the target and close test executors after each case. Repository tests should cover empty results, constraint violations, rollback, stream emissions, and migrations populated with real-looking data. When investigating performance, inspect the generated SQL and query plan, then add an index only for a demonstrated access path. Record query names and elapsed time in diagnostics, not the bound personal values. This preserves the chief benefit of typed queries while keeping privacy and operational evidence intact.

Queries that combine tables deserve tests for absent relationships as well as complete rows. Choose an inner or outer join deliberately, alias repeated column names, and map nullable joined records without manufacturing an empty domain object. For search, bind the pattern and define escaping for wildcard characters so a user-entered percent sign does not unexpectedly match everything. Drift streams should be transformed carefully: broad mapping or sorting after the query can repeat expensive work for every invalidation. Move stable filtering into SQL, then assert the emitted sequence when a related row is inserted, updated, and deleted.

Cancellation and screen disposal do not necessarily stop an already issued database statement, so repositories should ignore late UI delivery without corrupting their own state. For long imports, divide work into measured transactions and report committed progress between them. A single enormous transaction offers atomicity but can hold locks, expand journals, and make cancellation feel unresponsive.

Common mistakes

  • Choose Drift over raw sqflite deliberately: In Drift databases, a tiny one-table prototype may not justify code generation and additional abstractions; inspect this Drift databases cause before changing another Drift databases widget.
  • Split runtime and generator dependencies: In Drift databases, putting drift_dev in the shipped dependency graph increases noise and signals a broken setup; inspect this Drift databases cause before changing another Drift databases widget.
  • Describe columns in a Table class: In Drift databases, renaming a Dart getter without a migration can make Drift expect a column that existing databases lack; inspect this Drift databases cause before changing another Drift databases widget.
  • Declare the generated database and version: In Drift databases, forgetting to rerun build_runner leaves generated code out of sync with the source table; inspect this Drift databases cause before changing another Drift databases widget.
  • Write type-safe inserts and selects: In Drift databases, raw maps and string column names discard the compiler checks that justify choosing Drift; inspect this Drift databases cause before changing another Drift databases widget.
  • Stream query changes into the widget tree: In Drift databases, creating a fresh watched query in rapidly rebuilding widgets can cause unnecessary subscriptions; inspect this Drift databases cause before changing another Drift databases widget.

Frequently asked questions

How does choose Drift over raw sqflite deliberately work in Drift databases?

For Drift databases, the starting rule is that drift builds a typed Dart query layer over SQLite and can watch query results reactively. Apply this Drift databases rule first because choose Drift over raw sqflite deliberately determines whether the Drift databases pattern fits.

Why does describe columns in a Table class matter for Drift databases?

In Drift databases, stable table and column names matter because generated getters map onto the persisted SQLite schema. Keeping describe columns in a Table class at the Drift databases call site exposes the Drift databases return value directly.

What failure should I test first in Drift databases?

First reproduce the Drift databases case where raw maps and string column names discard the compiler checks that justify choosing Drift. The corrected query uses Drift’s generated columns and result types, allowing an invalid field reference to fail during development instead of after release.

How can I verify Drift databases before release?

Exercise migrate existing databases with MigrationStrategy with real Drift databases inputs on every shipped platform. Inspect the final Drift databases callback or output; a successful Drift databases button tap alone is not proof.

Further reads

Connect Drift databases to the surrounding Flutter stack through these published tutorials:

Sources: Flutter documentation and the package documentation on pub.dev; Drift databases examples verified against current stable Flutter.