Flutter + Isar: A Practical NoSQL Database Tutorial
Build a production-ready Flutter app with Isar: setup, models, indexes, links, queries, transactions, and reactive UI—complete with code you can reuse.
Image used for representation purposes only.
Overview
Isar is a fast, embeddable NoSQL database designed for Flutter and Dart. It runs natively on Android, iOS, macOS, Windows, Linux, and the web (Wasm), and it’s built for offline‑first apps that need low‑latency reads, safe concurrent writes, and reactive queries. This tutorial walks you through setup, modeling data, queries, relationships, transactions, and reactive UI patterns—ending with a tidy repository pattern you can drop into production.
When to choose Isar
- You want a schema‑aware, strongly typed, code‑generated API (no dynamic maps).
- You need native speed for large collections and complex filters.
- You prefer reactive streams and query builders over manual SQL.
- You’re building an offline‑first app with sync handled elsewhere.
If your team prefers hand‑written SQL or needs cross‑platform SQL compatibility, a SQL layer (e.g., Drift) might fit better. For key‑value stores with minimal querying, simpler options can work. For object graphs, Isar’s links and generated query builders are compelling.
Project setup
Add the core packages and code generator. Versions are examples—use the latest compatible releases.
# pubspec.yaml
name: isar_tutorial
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
isar: ^3.1.0
isar_flutter_libs: any # bundles native binaries for mobile/desktop
path_provider: any
dev_dependencies:
build_runner: any
isar_generator: any
Run code generation whenever you change models:
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
Tip: In watch mode during development, use flutter pub run build_runner watch -d.
Define collections (models)
Create strongly typed collections using annotations. The generator produces a <model>.g.dart with schemas, indexes, and a fluent query API.
// lib/models/user.dart
import 'package:isar/isar.dart';
part 'user.g.dart';
@collection
class User {
// Auto-incrementing primary key (Id is a typedef of int)
Id id = Isar.autoIncrement;
@Index(unique: true, caseSensitive: false)
late String email;
String? name;
int age = 0;
@Index()
late DateTime createdAt;
}
A second collection with a link to User:
// lib/models/task.dart
import 'package:isar/isar.dart';
import 'user.dart';
part 'task.g.dart';
@collection
class Task {
Id id = Isar.autoIncrement;
late String title;
bool done = false;
// Many tasks belong to one owner
final owner = IsarLink<User>();
}
@collection
class Project {
Id id = Isar.autoIncrement;
late String name;
// One project has many tasks
final tasks = IsarLinks<Task>();
}
Backlinks are supported too. If you want to navigate from User to owned tasks without manually maintaining the inverse side, add:
// In user.dart
@Backlink(to: 'owner')
final tasks = IsarLinks<Task>();
Rebuild after editing models.
Open the database
Open Isar once and share the instance app‑wide (e.g., via a service locator or provider). On Flutter, include isar_flutter_libs so the native binaries ship with your app.
// lib/services/db.dart
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import '../models/user.dart';
import '../models/task.dart';
import '../models/project.dart';
class AppDb {
AppDb._();
static final AppDb instance = AppDb._();
Isar? _isar;
Future<Isar> open() async {
if (_isar != null) return _isar!;
final dir = await getApplicationDocumentsDirectory();
_isar = await Isar.open(
[UserSchema, TaskSchema, ProjectSchema],
directory: dir.path,
inspector: true, // enable Isar Inspector in debug builds
// encryptionKey: await _load32ByteKey(), // optional
);
return _isar!;
}
}
Call this early in main():
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppDb.instance.open();
runApp(const MyApp());
}
CRUD in practice
All writes must happen in a transaction. Reads are fast and safe from any isolate.
// Create
final isar = await AppDb.instance.open();
final user = User()
..email = 'alice@example.com'
..name = 'Alice'
..age = 29
..createdAt = DateTime.now();
await isar.writeTxn(() async {
await isar.users.put(user); // insert or update by id
});
// Read by id
final fetched = await isar.users.get(user.id);
// Update (mutate then put)
await isar.writeTxn(() async {
fetched!..name = 'Alice L.'..age = 30;
await isar.users.put(fetched!);
});
// Delete
await isar.writeTxn(() async {
await isar.users.delete(user.id);
});
Batch writes are efficient:
await isar.writeTxn(() async {
await isar.tasks.putAll(taskList);
});
Indexes and fast lookups
Indexes power prefix, equality, range queries, sorting, and distinct. Because email is a unique indexed field, a typed accessor is generated:
final alice = await isar.users.getByEmail('alice@example.com');
Common query patterns using the generated builder:
// Filter and sort
final newcomers = await isar.users
.filter()
.ageLessThan(25)
.sortByCreatedAtDesc()
.limit(50)
.findAll();
// String prefix on an indexed field
final aUsers = await isar.users
.where()
.emailStartsWith('a', caseSensitive: false)
.findAll();
// Distinct by property
final cities = await isar.users
.filter()
.ageGreaterThan(17)
.distinctByName(caseSensitive: false)
.findAll();
Tip: Keep indexes lean. Index every field you filter/sort on frequently, but avoid indexing rarely queried fields.
Relationships with links
Create and save links after persisting the objects.
final alice = User()
..email = 'alice@example.com'
..name = 'Alice'
..createdAt = DateTime.now();
final t1 = Task()..title = 'Design wireframes';
final t2 = Task()..title = 'Implement auth';
await isar.writeTxn(() async {
await isar.users.put(alice);
await isar.tasks.putAll([t1, t2]);
// Link tasks to Alice (many->one via Task.owner)
t1.owner.value = alice;
t2.owner.value = alice;
await t1.owner.save();
await t2.owner.save();
});
// Navigate the relationship
final tasksForAlice = await alice.tasks.filter().doneEqualTo(false).findAll();
For many‑to‑many, use IsarLinks<T> on both sides and manage the sets.
Reactive queries (live updates)
Turn queries into streams that emit whenever matching objects change. Great for building responsive UIs without manual refresh logic.
// All open tasks stream
final stream = isar.tasks
.filter()
.doneEqualTo(false)
.watch(initialReturn: true);
// In a widget
StreamBuilder<List<Task>>(
stream: stream,
builder: (context, snapshot) {
final items = snapshot.data ?? const [];
// ...build list
return ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => ListTile(title: Text(items[i].title)),
);
},
);
You can also watch a single object or the whole collection lazily to be notified that “something changed” without transferring data.
Transactions and concurrency
- Wrap all writes in
await isar.writeTxn(() async { ... }). - Reads can happen anywhere; they see a consistent snapshot.
- Avoid long‑running work inside a write transaction; compute first, then write.
- Nesting write transactions is unnecessary—reuse the outer transaction or restructure.
Future<void> toggleDone(Isar isar, Task t) async {
await isar.writeTxn(() async {
t.done = !t.done;
await isar.tasks.put(t);
});
}
Pagination and search patterns
- Offset/limit for simple paging:
final page = await isar.users
.filter()
.ageGreaterThan(0)
.sortByCreatedAtDesc()
.offset(20)
.limit(20)
.findAll();
- Keyset pagination (preferred at scale): store the last item’s sort key and query “less than” or “greater than” for the next page.
Future<List<User>> pageAfter(DateTime lastCreatedAt) => isar.users
.filter()
.createdAtLessThan(lastCreatedAt)
.sortByCreatedAtDesc()
.limit(20)
.findAll();
Error handling and durability
- Always
awaitdatabase operations to avoid unhandled exceptions. - Consider an app‑wide error handler that logs failed transactions.
- Keep a single shared Isar instance per process.
- Test schema changes behind feature flags before broad rollout.
Lightweight repository pattern
Encapsulate access for testability and separation of concerns.
// lib/repositories/task_repository.dart
import 'package:isar/isar.dart';
import '../models/task.dart';
class TaskRepository {
final Isar isar;
TaskRepository(this.isar);
Stream<List<Task>> watchOpen() => isar.tasks
.filter()
.doneEqualTo(false)
.sortByIdDesc()
.watch(initialReturn: true);
Future<Task> create(String title) async {
final t = Task()..title = title;
await isar.writeTxn(() async => isar.tasks.put(t));
return t;
}
Future<void> toggle(int id) async {
final t = await isar.tasks.get(id);
if (t == null) return;
await isar.writeTxn(() async {
t.done = !t.done;
await isar.tasks.put(t);
});
}
Future<int> clearDone() => isar.writeTxn(() async =>
isar.tasks.filter().doneEqualTo(true).deleteAll());
}
Usage in a widget tree:
final isar = await AppDb.instance.open();
final repo = TaskRepository(isar);
return StreamBuilder<List<Task>>(
stream: repo.watchOpen(),
builder: (context, snapshot) {
final tasks = snapshot.data ?? const [];
// ... render tasks
return Column(
children: [
for (final t in tasks)
CheckboxListTile(
value: t.done,
onChanged: (_) => repo.toggle(t.id),
title: Text(t.title),
)
],
);
},
);
Testing tips
- Use a temporary directory for isolated test databases.
- Seed data in a
setUpblock; tear down by deleting the directory. - Keep tests deterministic—avoid
DateTime.now()by injecting clocks.
import 'dart:io';
import 'package:isar/isar.dart';
import 'package:test/test.dart';
void main() {
late Directory dir;
late Isar isar;
setUp(() async {
dir = await Directory.systemTemp.createTemp('isar_test_');
isar = await Isar.open([/* Schemas */], directory: dir.path);
});
tearDown(() async {
await isar.close();
await dir.delete(recursive: true);
});
test('writes and reads', () async {
// ...
});
}
Performance checklist
- Minimize the number of write transactions by batching (
putAll,deleteAll). - Index fields used for filters/sorts; avoid over‑indexing.
- Prefer keyset pagination over large offsets.
- Use reactive
watchto avoid requerying on every frame. - Keep objects small; store large blobs externally if possible.
Common pitfalls
- Forgetting to run the generator after changing models—queries won’t compile.
- Creating multiple Isar instances unintentionally—share one per process.
- Not saving links after setting them—call
save()onIsarLink/IsarLinks. - Doing heavy computation inside
writeTxn—compute first, then write.
Conclusion
Isar gives Flutter apps a fast, typed NoSQL store with excellent developer ergonomics. With code‑generated queries, first‑class links, reactive streams, and simple transactions, you can model real‑world data and build responsive UIs efficiently. Start small with one or two collections, add targeted indexes, wire up reactive lists, and grow from there using the repository pattern above.
Related Posts
Mastering Complex SQLite Queries in Flutter with sqflite: A Practical Tutorial
Learn how to write and optimize complex SQLite queries in Flutter using sqflite: JOINs, subqueries, CTEs, pagination, indexes, and transactions.
Flutter Hive Database Tutorial: Fast, Typed, and Offline‑First
Learn Flutter Hive database from setup to adapters, reactive UI, encryption, migrations, testing, and performance tips—with clear code examples.
Flutter Speech Recognition (STT) Tutorial: Real‑Time Transcription on Android & iOS
Build a Flutter speech-to-text app with speech_to_text: setup, permissions, real-time UI, locales, and pro tips for accuracy, UX, and scalability.