Skip to content
Blog

Drift for Flutter: Type-Safe Reactive SQLite Database

Learn how to build robust local databases in Flutter with Drift, including type-safe tables, SQL queries, reactive streams, transactions, migrations, and code generation.

Published on September 21, 2026

AI Assistant

When a Flutter application needs more than simple key-value storage, SQLite is often the natural next step. It provides relational data, transactions, indexes, joins, and mature query capabilities.

The challenge is that using SQLite directly usually means dealing with raw SQL, manually mapping rows into Dart objects, handling migrations, and keeping application code synchronized with the database schema.

Drift solves much of this problem.

Drift is a reactive persistence library for Dart and Flutter built on top of SQLite. It combines SQL with Dart APIs and generates type-safe database code from your schema and queries.

As of September 2026, the current stable release is Drift 2.35.0. The package supports Android, iOS, macOS, Windows, Linux, and Web.

Why Drift?

A traditional SQLite workflow often looks like this:

flowchart TD
    UI["Flutter UI"]
    Repo["Repository<br/>Application-facing API"]
    DAO["DAO<br/>Database operations"]
    Drift["Drift<br/>Query Builder + SQL + ORM"]
    SQLite["SQLite"]

    UI --> Repo
    Repo --> DAO
    DAO --> Drift
    Drift --> SQLite

Drift provides a more structured approach:

flowchart TD
  UI["Flutter UI"]
  Repo["Repository / DAO"]
  API["Drift generated API"]
  SQLite["SQLite"]

  UI --> Repo
  Repo --> API
  API --> SQLite

The database schema becomes part of the Dart application’s type system.

Drift can generate Dart classes for tables, rows, companions, queries, and database operations. It can also turn queries into reactive streams that automatically emit new results when their underlying data changes.

This makes Drift particularly useful for applications such as:

  • Offline-first applications
  • Todo and productivity apps
  • Chat applications
  • Inventory systems
  • POS applications
  • Note-taking applications
  • Caching layers
  • Local-first applications
  • Applications that synchronize with a remote backend

Installing Drift

For a Flutter project, add Drift and its development tools:

flutter pub add drift
flutter pub add dev:drift_dev
flutter pub add dev:build_runner

For native database access, Drift uses SQLite through its native database implementation.

A typical dependency configuration is:

dependencies:
  flutter:
    sdk: flutter

  drift: ^2.35.0
  path_provider: ^2.1.5

dev_dependencies:
  build_runner: ^2.7.1
  drift_dev: ^2.35.0

Always check the current package versions before starting a new project because Drift and its generator are actively maintained. The current Drift release is 2.35.0.

Creating a Database

Let’s build a small task application.

Our database will contain a tasks table:

tasks
 ├── id
 ├── title
 ├── description
 ├── completed
 └── createdAt

Create:

lib/
├── database/
│   ├── app_database.dart
│   └── tables.dart
└── main.dart

Defining a Table

Create tables.dart:

import 'package:drift/drift.dart';

class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();

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

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

  BoolColumn get completed =>
      boolean().withDefault(const Constant(false))();

  DateTimeColumn get createdAt =>
      dateTime().withDefault(currentDateAndTime)();
}

This Dart class represents a SQLite table.

Drift will generate the corresponding database representation and Dart data classes.

For example, the generated row type will provide strongly typed properties such as:

task.id
task.title
task.description
task.completed
task.createdAt

This is different from working with:

Map<String, dynamic>

where every field must be accessed and converted manually.

Creating the Database Class

Create app_database.dart:

import 'package:drift/drift.dart';

import 'tables.dart';

part 'app_database.g.dart';

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase(super.e);

  @override
  int get schemaVersion => 1;
}

The important parts are:

part 'app_database.g.dart';

and:

@DriftDatabase(tables: [Tasks])

The first tells Dart that generated code will be included.

The second tells Drift which tables belong to the database.

Generating the Code

Run:

dart run build_runner build

During development, you can use:

dart run build_runner watch

This watches your source files and regenerates Drift code whenever your schema changes.

The generated file:

app_database.g.dart

should generally not be edited manually.

Instead, modify the source schema and regenerate the code.

Opening the SQLite Database

For a native Flutter application, Drift provides NativeDatabase.

A simple database factory can look like this:

import 'dart:io';

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

Future<AppDatabase> openDatabase() async {
  final directory = await getApplicationDocumentsDirectory();

  final file = File(
    p.join(directory.path, 'app.sqlite'),
  );

  return AppDatabase(
    NativeDatabase.createInBackground(file),
  );
}

NativeDatabase uses Dart FFI to access SQLite and supports running the database in a background isolate.

This is useful because database work does not need to block the main Flutter UI isolate.

Inserting Data

Drift generates companion classes for inserts.

For example:

final taskId = await db.into(db.tasks).insert(
  TasksCompanion.insert(
    title: 'Learn Drift',
    description: const Value(
      'Build a local SQLite database with Flutter',
    ),
  ),
);

Notice that the generated API understands the database schema.

The description field is nullable, so Drift uses:

Value<String?>

to distinguish between an omitted value and an explicitly supplied value.

Reading Data

A simple query:

final tasks = await db.select(db.tasks).get();

The result is strongly typed.

You can also filter:

final incompleteTasks = await (
  db.select(db.tasks)
    ..where((task) => task.completed.equals(false))
).get();

And order the results:

final tasks = await (
  db.select(db.tasks)
    ..orderBy([
      (task) => OrderingTerm.desc(task.createdAt),
    ])
).get();

The query builder allows you to express SQL operations using Dart while preserving type information. Drift also supports writing SQL directly when that is more appropriate.

Updating Data

Suppose the user completes a task.

await (
  db.update(db.tasks)
    ..where((task) => task.id.equals(taskId))
).write(
  const TasksCompanion(
    completed: Value(true),
  ),
);

The generated API ensures that the update matches the table definition.

Deleting Data

Deleting a task is similarly straightforward:

await (
  db.delete(db.tasks)
    ..where((task) => task.id.equals(taskId))
).go();

You can also delete all completed tasks:

await (
  db.delete(db.tasks)
    ..where((task) => task.completed.equals(true))
).go();

Reactive Queries

One of Drift’s most useful features is reactive queries.

Instead of executing:

get()

you can call:

watch()

For example:

final tasksStream = db.select(db.tasks).watch();

The query returns a Stream<List<Task>>.

Whenever the underlying table changes, Drift can emit an updated result.

In Flutter:

StreamBuilder<List<Task>>(
  stream: db.select(db.tasks).watch(),
  builder: (context, snapshot) {
    final tasks = snapshot.data ?? [];

    return ListView.builder(
      itemCount: tasks.length,
      itemBuilder: (context, index) {
        final task = tasks[index];

        return ListTile(
          title: Text(task.title),
          trailing: Checkbox(
            value: task.completed,
            onChanged: (_) {},
          ),
        );
      },
    );
  },
);

This removes a common pattern in Flutter applications:

flowchart TD
  A[Database changed] --> B[Manually reload data]
  B --> C["setState()"]
  C --> D[Rebuild UI]

With Drift:

flowchart TD
  A[Database changed] --> B[Reactive query]
  B --> C[Stream emits]
  C --> D[Flutter rebuilds]

Drift supports reactive streams even for complex queries involving multiple tables.

Writing SQL Directly

Drift does not force you to use the Dart query builder.

For applications with complex SQL, you can write SQL directly.

For example:

final result = await customSelect(
  '''
  SELECT *
  FROM tasks
  WHERE completed = 0
  ORDER BY created_at DESC
  ''',
).get();

Drift’s SQL tooling can analyze SQL queries and generate Dart representations for their results.

This is one of the important differences between Drift and a simple ORM abstraction: SQL remains a first-class part of the system.

Creating a DAO

As an application grows, database operations should not all live inside the database class.

Drift supports Data Access Objects, or DAOs.

For example:

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

  TaskDao(super.db);

  Stream<List<Task>> watchAll() {
    return (select(tasks)
          ..orderBy([
            (task) => OrderingTerm.desc(task.createdAt),
          ]))
        .watch();
  }

  Future<int> createTask(String title) {
    return into(tasks).insert(
      TasksCompanion.insert(
        title: title,
      ),
    );
  }

  Future<void> completeTask(int id) {
    return (update(tasks)
          ..where((task) => task.id.equals(id)))
        .write(
          const TasksCompanion(
            completed: Value(true),
          ),
        );
  }
}

Then register the DAO:

@DriftDatabase(
  tables: [Tasks],
  daos: [TaskDao],
)
class AppDatabase extends _$AppDatabase {
  AppDatabase(super.e);

  @override
  int get schemaVersion => 1;
}

This produces a cleaner architecture:

flowchart TD
    UI["Flutter UI"]
    Repository["Repository"]
    DAO["DAO"]
    Drift["Drift"]
    SQLite["SQLite"]

    UI --> Repository
    Repository --> DAO
    DAO --> Drift
    Drift --> SQLite

Transactions

Database applications frequently need multiple operations to succeed or fail together.

For example:

await db.transaction(() async {
  await db.into(db.tasks).insert(
    TasksCompanion.insert(
      title: 'First task',
    ),
  );

  await db.into(db.tasks).insert(
    TasksCompanion.insert(
      title: 'Second task',
    ),
  );
});

If an error occurs inside the transaction, the transaction can be rolled back.

Transactions are especially useful for operations such as:

  • Creating an order and its items
  • Moving money between accounts
  • Importing related records
  • Synchronizing multiple tables
  • Updating counters and associated records

Drift has built-in transaction support as part of its database API.

Database Migrations

Real applications rarely have a database schema that remains unchanged.

Suppose version 1 contains:

tasks
 ├── id
 ├── title
 └── completed

Later we want to add:

priority

The schema version changes:

@override
int get schemaVersion => 2;

Then define the migration:

@override
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (Migrator m) async {
    await m.createAll();
  },

  onUpgrade: (Migrator m, int from, int to) async {
    if (from < 2) {
      await m.addColumn(
        tasks,
        tasks.priority,
      );
    }
  },
);

Drift provides migration utilities and schema verification tools to help keep generated schema definitions synchronized with the actual database.

For production applications, migration testing should be treated as part of the release process.

Schema Verification

Database migrations can become complicated as an application evolves.

Drift provides migration tooling that can compare expected schemas and detect mismatches. Its migration APIs include schema verification utilities such as SchemaVerifier.

This is particularly useful when an application has many versions:

flowchart TD
    V1["v1"] --> V2["v2"]
    V2 --> V3["v3"]
    V3 --> V4["v4"]
    V4 --> V5["v5"]

Instead of assuming every migration works, the migration process can be tested against real historical schemas.

Drift 2.35.0 also adds Dart API support for SQLite FTS5 queries.

The release includes APIs such as:

match
matchExp
highlight
snippet
bm25
rank

through:

package:drift/extensions/fts5.dart

This makes Drift interesting for applications that need local search capabilities such as note-taking applications, document readers, and offline knowledge bases.

A conceptual architecture could look like:

flowchart TD
    Flutter["Flutter"]
    SearchUI["Search UI"]
    DAO["Drift DAO"]
    FTS5["SQLite FTS5"]
    Documents["Local Documents"]

    Flutter --> SearchUI
    SearchUI --> DAO
    DAO --> FTS5
    FTS5 --> Documents

Drift and Repository Architecture

A practical Flutter application can separate responsibilities into layers.

flowchart TD
    UI["Flutter UI"]
    Repo["Repository<br/>Application-facing API"]
    DAO["DAO<br/>Database operations"]
    Drift["Drift<br/>Query Builder + SQL + ORM"]
    SQLite["SQLite"]

    UI --> Repo
    Repo --> DAO
    DAO --> Drift
    Drift --> SQLite

The repository can expose application-oriented operations instead of exposing database implementation details.

For example:

class TaskRepository {
  final TaskDao dao;

  TaskRepository(this.dao);

  Stream<List<Task>> watchTasks() {
    return dao.watchAll();
  }

  Future<int> addTask(String title) {
    return dao.createTask(title);
  }

  Future<void> complete(int id) {
    return dao.completeTask(id);
  }
}

The UI then depends on:

TaskRepository

rather than directly depending on SQLite.

Drift for Offline-First Applications

Drift becomes particularly interesting when building offline-first applications.

Consider a synchronization architecture:

flowchart TD
    UI["Flutter UI"]
    Drift["Drift<br/>Local SQLite"]
    Queue["Sync Queue"]
    API["Remote API"]
    Cloud["Cloud Database"]

    UI --> Drift
    Drift --> Queue
    Queue --> API
    API --> Cloud

The application can write to Drift first.

The UI immediately sees the local state through reactive queries.

A background synchronization process can then synchronize changes with the server.

This architecture can provide a much better experience when network connectivity is unreliable.

Drift vs Key-Value Storage

Drift is not intended to replace every storage mechanism.

For simple preferences:

theme = dark
language = en
onboardingComplete = true

a key-value store may be simpler.

For structured relational data:

users
orders
order_items
products
messages
documents

SQLite and Drift become much more appropriate.

A useful rule is:

flowchart TD
    Simple["Simple configuration"] --> KV["Key-value storage"]
    Relational["Structured relational data"] --> Drift["Drift / SQLite"]
    Remote["Remote synchronized data"] --> API["API + Local Persistence"]

When Should You Use Drift?

Drift is particularly useful when your Flutter application needs:

  • Relational data
  • Complex queries
  • Joins
  • Transactions
  • Offline persistence
  • Reactive database streams
  • Type-safe database APIs
  • Database migrations
  • SQL queries
  • Full-text search
  • Large local datasets
  • Cross-platform database support

It may be unnecessary for a small application that only needs to save a handful of preferences.

Production Considerations

A production Drift application should consider several areas.

  • Keep migrations under version control: Database schema changes should be reviewed just like application code.
  • Test migrations: Don’t only test a fresh database. Test upgrades from previous schema versions.
  • Keep database operations out of widgets: Widgets should not contain complex SQL. Use repositories and DAOs.
  • Use transactions for related writes: If several changes must succeed together, use a transaction.
  • Use reactive queries where appropriate: watch() can simplify synchronization between database state and Flutter UI.
  • Consider background execution: For large queries or heavy database workloads, Drift’s isolate support can help keep database operations away from the UI isolate. Drift explicitly provides built-in threading support and native background database capabilities.

A Minimal Complete Example

The core of a small Drift application can be summarized as:

import 'package:drift/drift.dart';

part 'app_database.g.dart';

class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get title => text()();

  BoolColumn get completed =>
      boolean().withDefault(const Constant(false));
}

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase(super.e);

  @override
  int get schemaVersion => 1;

  Future<int> addTask(String title) {
    return into(tasks).insert(
      TasksCompanion.insert(
        title: title,
      ),
    );
  }

  Stream<List<Task>> watchTasks() {
    return select(tasks).watch();
  }

  Future<void> completeTask(int id) {
    return (update(tasks)
          ..where((task) => task.id.equals(id)))
        .write(
          const TasksCompanion(
            completed: Value(true),
          ),
        );
  }
}

The important idea is not the amount of code.

It is the relationship between the schema, generated types, queries, and reactive streams.

flowchart TD
    Schema["Schema"]
    CodeGen["Code Generation"]
    API["Type-safe Dart API"]
    SQLite["SQLite"]
    Stream["Reactive Stream"]
    UI["Flutter UI"]

    Schema --> CodeGen
    CodeGen --> API
    API --> SQLite
    SQLite --> Stream
    Stream --> UI

Conclusion

Drift brings the relational capabilities of SQLite into a strongly typed Dart development workflow.

Instead of choosing between raw SQL and a highly abstract database layer, Drift allows developers to use both Dart’s type system and SQL where each is most useful.

Its combination of:

  • Type-safe generated code
  • SQL support
  • Reactive queries
  • Transactions
  • Schema migrations
  • DAOs
  • Background database execution
  • Cross-platform support
  • SQLite and FTS5 capabilities

makes it a strong option for Flutter applications that need serious local persistence.

For simple application preferences, a lightweight key-value store may be enough. But once your application starts looking like a real relational system—with users, messages, documents, orders, tasks, synchronization, or search—Drift provides a structured foundation that can grow with the application.

The key architectural principle is simple:

flowchart TD
    UI["Flutter UI"]
    Repository["Application / Repository"]
    DAO["DAO"]
    Drift["Drift"]
    SQLite["SQLite"]

    UI --> Repository
    Repository --> DAO
    DAO --> Drift
    Drift --> SQLite

Keep the database strongly typed, make migrations explicit, use transactions for consistency, and use reactive queries when the UI should automatically follow database changes.

That combination turns SQLite from a low-level storage engine into a first-class part of a modern Flutter architecture.