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.

ASOasis
7 min read
Flutter + Isar: A Practical NoSQL Database Tutorial

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.

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 await database 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 setUp block; 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 watch to 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() on IsarLink/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