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.
Full-Text Search
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.