High-performance, reactive SQLite for Dart and Flutter.
Write plain SQL. Stream anything. No main isolate jank. No ORM. No codegen.
final db = await Database.open('app.db');
// Reads and writes stay off your UI thread.
final users = await db.select('SELECT * FROM users WHERE active = ?', [1]);
await db.execute('INSERT INTO users(name) VALUES (?)', ['Ada']);
// Reactive queries — automatic dependency detection using SQLite's
// authorizer hook and the pre-update hook for column-aware invalidation.
db.stream('SELECT * FROM users WHERE active = ?', [1]).listen((users) {
setState(() => this.users = users);
});
// Transactions — reads inside see uncommitted writes.
await db.transaction((tx) async {
await tx.execute('INSERT INTO users(name) VALUES (?)', ['Sonja']);
final rows = await tx.select('SELECT COUNT(*) as c FROM users');
print('total: ${rows.first['c']}');
});Visit the project site to browse interactive experiments and benchmarks.
- Zero main-isolate jank. Reads, writes, and reactive re-queries all run on persistent worker isolates. A 5,000-row query uses sub-millisecond main-isolate time.
- Reactive SQL.
db.stream(sql)turns table-backed queries into live streams. Dependencies are detected automatically — works with JOINs, subqueries, views, CTEs. No table lists to maintain. - Column-aware invalidation. Writes to unrelated columns do not wake streams that cannot change. Identical queries are deduplicated, unchanged results are suppressed, and uncertain metadata falls back safely to table-level invalidation.
- Just SQL.
select,execute,executeBatch,transaction,stream. No ORM, no query builder, no code generation. - Encryption. Optional AES-256 encryption via SQLite3 Multiple Ciphers. Same API — just pass a key.
resqlite is designed to work in the background and keep apps running smooth. Reads, writes, and stream queries all run on background worker isolates. The main isolate only receives finished results.
| Metric | Wall time | Main isolate time |
|---|---|---|
| Point query (1 row) | 0.010ms | 0.010ms |
| 1,000-row select() | 0.39ms | 0.09ms |
| 10,000-row select() | 4.71ms | 0.90ms |
| Batch insert (1,000 rows) | 0.41ms | 0.41ms |
| Stream invalidation | 0.06ms | 0.06ms |
~104K point queries/sec. 3x faster wall-clock reads and 13x less main-isolate time at 1K rows compared to synchronous alternatives. Sub-millisecond stream invalidation.
Measured on a 10-core Apple M1 Pro, Dart 3.11, macOS 26.2. Results will vary by hardware. The sqlite3 package is a great choice for synchronous workloads; sqlite_async (PowerSync) offers production-tested streaming with built-in throttling. resqlite is optimized for Flutter apps where main-isolate time is the critical constraint.
See the full comparison in the interactive benchmark dashboard, or run the benchmarks on your machine and add your results.
db.stream('SELECT * FROM users WHERE active = ?', [1]).listen((users) {
setState(() => this.users = users);
});That's the entire reactive API. Under the hood:
- Automatic dependency tracking via SQLite's authorizer hook — no manual table lists
- Column-aware dispatch — writes to columns outside a stream's projection are skipped when SQLite metadata is precise
- Deduplication — 100 widgets watching the same query = 1 actual SQLite query per write
- Unchanged suppression — writes that don't change your query's results are silently filtered
- Immediate — re-queries fire on write commit, not on a timer
Virtual table limitation: SQLite's preupdate hook does not fire for virtual-table writes (FTS5, R-Tree, etc.), so streams over virtual tables do not auto-invalidate. For external-content FTS, join the real content table in the streamed query so normal table invalidation applies. For other cases, use select instead of stream.
final db = await Database.open('app.db');
// Reads
final rows = await db.select('SELECT * FROM users WHERE id = ?', [42]);
final json = await db.selectBytes('SELECT * FROM users'); // JSON serialized in C — no Dart object allocation
// Writes
final result = await db.execute('INSERT INTO users(name) VALUES (?)', ['Ada']);
await db.executeBatch('INSERT INTO users(name) VALUES (?)', [['Ada'], ['Grace']]); // bulk inserts in a single transaction
// Transactions
await db.transaction((tx) async {
await tx.execute('INSERT INTO users(name) VALUES (?)', ['Ada']);
final rows = await tx.select('SELECT COUNT(*) as c FROM users');
return rows.first['c'];
});
// Reactive streams
db.stream('SELECT * FROM users ORDER BY id').listen((rows) { ... });
// Encryption
final db = await Database.open('secure.db', encryptionKey: '0123...abcdef');
await db.close();Your UI renders at 60fps — 16ms per frame. A 5,000-row select takes 2.5ms total, but only 0.65ms on the main isolate:
final items = await db.select(
'SELECT id, name, price FROM products ORDER BY name',
);
// Row objects are created lazily — only the rows you access get materialized.
for (final item in items) {
print('${item['name']}: \$${item['price']}');
}The expensive work — SQLite stepping, string decoding, result building — runs on a persistent background worker. The main isolate receives an optimized array and wraps it in lightweight Row views on access.
Create a reactive stream and use it with the standard StreamBuilder. The library handles invalidation, deduplication, and change detection:
class TaskDashboard extends StatefulWidget { ... }
class _TaskDashboardState extends State<TaskDashboard> {
// Create streams once — not on every build.
late final _pendingCount = db.stream(
'SELECT COUNT(*) as c FROM tasks WHERE done = 0',
);
late final _myTasks = db.stream(
'SELECT * FROM tasks WHERE assigned_to = ? ORDER BY due',
[userId],
);
@override
Widget build(BuildContext context) => Column(children: [
StreamBuilder(
stream: _pendingCount,
builder: (context, snap) => Text('${snap.data?.first['c']} remaining'),
),
StreamBuilder(
stream: _myTasks,
builder: (context, snap) => TaskList(tasks: snap.data ?? []),
),
]);
}When a write hits the tasks table:
- resqlite looks up affected streams via an inverted index — no scanning.
- If SQLite reported precise column metadata, streams whose selected columns are disjoint from the write do not wake up.
- Streams on other tables do not wake up; uncertain column metadata falls back to table-level re-query.
- The worker hashes the new result. If the data hasn't changed, nothing is sent back and no work is done on the main isolate.
- If it changed, the
StreamBuilderreceives the new data and rebuilds.
selectBytes produces JSON directly in C — no Dart object allocation for the result data:
Future<Response> handleProducts(Request request) async {
final bytes = await db.selectBytes(
'SELECT id, name, price FROM products WHERE active = ?',
[1],
);
return Response.ok(bytes, headers: {'content-type': 'application/json'});
}String escaping, number formatting, and JSON structure are handled in native code. The result crosses to Dart as a single Uint8List. At 1,000 rows this is 5× faster than building Dart maps and calling jsonEncode, and uses 0ms of main-isolate time.
executeBatch runs one prepared statement across many parameter sets in a single transaction — one prepare, one commit, no per-row overhead:
await db.executeBatch(
'INSERT OR REPLACE INTO products(id, name, price) VALUES (?, ?, ?)',
serverRows.map((r) => [r['id'], r['name'], r['price']]).toList(),
);1,000 rows in ~0.4ms. All-or-nothing atomicity — a crash mid-import leaves zero partial rows. Streams watching the table fire once on commit, not per row.
- Reads go through a persistent reader pool (2-4 workers with dedicated C connections)
- Writes go through a single persistent writer isolate
- Streams use SQLite's authorizer hook for table/column dependency tracking and preupdate hook for column-aware write invalidation
- Large results use hybrid transmission —
SendPortfor small, zero-copyIsolate.exitfor large
See the full architecture breakdown for how the reader pool, writer isolate, and stream engine fit together.
dependencies:
resqlite: ^0.3.1Or via the CLI:
dart pub add resqlite
flutter pub add resqliteRequires native Dart/Flutter builds (not web). The C code compiles automatically via Dart's native asset hooks — no manual build step.
resqlite does not include a migration framework — schema management is done with plain SQL:
await db.execute('CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY, name TEXT)');For versioned migrations, track a schema version in PRAGMA user_version or a metadata table and run your DDL accordingly. This keeps resqlite close to raw SQL and leaves schema tooling to your application.
- Homepage — project overview, architecture, and write-up
- Interactive Benchmarks — compare performance over time and across devices
- API Reference — full Dart API docs
- Architecture overview — how the reader pool, writer isolate, and stream engine fit together
- Experiment log — 110+ documented experiments with benchmarks and reasoning behind every design decision
- Benchmark suite — run the full suite yourself, or see community results across hardware
