Typed, fpdart-first faรงades over hive_ce boxes. No nulls,
no bare Futures, no hand-written CRUD. Pick the box that fits your data and get on with it.
โฌ๏ธ Upgrading from
0.0.x? 1.0 is a from-scratch rewrite with a new API, but your data almost always reads in place. MIGRATION.md walks you through it.
Hive is already fast. What it doesn't hand you is a surface that's pleasant to live with, and that's the gap this fills:
- ๐ฏ Absence is a type, not
null. Reads return anOption(orTaskOption), so "not there" is a case the compiler makes you handle, never a crash waiting to happen. - ๐งฐ CRUD is already written. get, put, update, delete, clear and watch ship on every box, so you stop rewriting the same boilerplate for every type you store.
- ๐งฉ Four boxes for four real shapes of data, each in an eager and a lazy flavour, so the box fits the problem instead of the other way round.
- ๐ You keep Hive's speed. The typed surface benchmarks within noise of raw
hive_ce, and the numbers below back that up instead of hand-waving it.
Pure Dart, so it runs anywhere Hive does: Flutter apps, Dart servers, CLIs, and the web.
- ๐งญ Which box?
- ๐ Quickstart
- ๐งฐ The box families
- ๐๏ธ Make it yours
- ๐ Eager or lazy? (measured)
- ๐ก๏ธ Safer than raw hive, at raw-hive speed
- ๐บ๏ธ Roadmap
๐งญ Which box?
Start here. Match what you're storing to a family, then grab its eager or lazy variant.
| You're storing | Reach for | Real-world fit |
|---|---|---|
| Many values, one key each | KeyedBox<T, K> |
users, todos, cache entries |
| Exactly one value | SingleValueBox<T> |
a session token, the theme, one config blob |
| A list of values per key | IterableBox<T, K> |
tags per post, history per day |
| Values addressed by two parts | DualKeyBox<T, K1, K2> |
(user, day) events, (row, column) grids |
Every family has an eager and a Lazy... twin; Eager or lazy? picks
the axis with measured numbers. Reverse queries ("everything for this user") live on the
dual-key family.
๐ Quickstart
1. Install
dart pub add hive_box_manager hive_ce
2. Wire up Hive once
Engine setup stays hive_ce's, exactly as its docs show:
import 'package:hive_ce/hive.dart';
Hive.init(appDataDirectory.path); // Hive.initFlutter() in Flutter apps
Hive.registerAdapter(TodoAdapter()); // usually generated by hive_ce_generator
None of hive_ce's symbols are re-exported. This package wraps boxes; the engine stays yours.
3. Open a box and use it
import 'package:hive_box_manager/hive_box_manager.dart';
final todos = await KeyedBox.open<Todo, int>('todos').run();
await todos.put(1, Todo('write the README')).run();
final found = todos.get(1); // Option<Todo>: Some when present, None when absent
final orEmpty = todos.getOr(2, Todo.empty());
todos.watch().listen((event) => print('${event.key} -> ${event.value}'));
That's the whole loop. Reads come straight off the in-memory cache, so they're synchronous;
writes are lazy Tasks you run() at the edge. And since you get the box from an open
factory, holding one means it's already open. There's no init step to forget.
๐งฐ The box families
Here's the good part: all four families wear the same surface, so you learn it once and it carries everywhere. The shape, in short:
- Absence is always
Option/TaskOption, nevernulland never a magic default. - Effects are always
Tasks. Nothing touches disk until you.run(), so pipelines compose first and fire once. watchis typed, andclose()/deleteFromDisk()are terminal (reacquire, don't reuse).
๐ The full method table (KeyedBox shown; the others mirror it)
KeyedBox<T, K> |
LazyKeyedBox<T, K> |
|
|---|---|---|
get(key) |
Option<T> |
TaskOption<T> |
getOr(key, fallback) |
T |
Task<T> |
values |
Iterable<T> |
Task<List<T>> |
keys / contains / length |
sync | sync, after first open |
put / putAll / delete / deleteAll / clear |
Task<Unit> |
Task<Unit> |
update(key, fn, {ifAbsent}) |
Task<T> |
Task<T> |
watch({key}) |
Stream<TypedBoxEvent<T, K>> |
Stream<LazyTypedBoxEvent<T, K>> |
flush / compact / close / deleteFromDisk |
Task<Unit> |
Task<Unit> |
Watch is typed on both axes: an eager delete event still carries the value that was removed; a
lazy event carries an Option<T> that's None on deletes, because a lazy box holds no value to
hand back.
๐ KeyedBox
Many values, each under its own key. The everyday workhorse.
Eager and lazy, side by side
Eager keeps every value in memory once the box opens, so reads are synchronous:
final todos = await KeyedBox.open<Todo, int>('todos').run();
await todos.put(1, Todo('write the README')).run();
final found = todos.get(1); // Option<Todo>
Lazy constructs synchronously and opens itself on the first effect. Values come off disk per read, so reads are effects too:
final archive = LazyKeyedBox<Todo, String>('archive');
await archive.put('2025-w1', Todo('ship 1.0')).run(); // first effect auto-opens
final found = await archive.get('2025-w1').run(); // TaskOption<Todo>
await archive.ensureInitialised().run(); // optional explicit warm-up
๐ SingleValueBox
One value, no keys. A token, a theme, one config blob.
Set, read, clear, watch
final session = await SingleValueBox.open<String>('session_token').run();
await session.set('abc123').run();
final token = session.get(); // Option<String>
await session.clear().run(); // the one unset; the next get() is None
session.watch().listen((token) => print(token)); // Some on set, None on clear
The value sits under a fixed internal slot, the same one 0.0.x single boxes used, so that data
reads in place. LazySingleValueBox is the same surface on the lazy axis. update mirrors
Map.update, and getOr(fallback) reads with a default.
๐๏ธ IterableBox
A list of values per key. Tags on a post, history for a day.
List ergonomics, and the sharp edge it files off
Hive reads collections back as List<dynamic> no matter what you wrote
(issue #150), so a plain
Box<List<Person>> opens fine and then throws on the first read after a restart. IterableBox
restores the element type at the read boundary and stacks list helpers on top:
final tags = await IterableBox.open<String, int>('post_tags').run();
await tags.put(1, ['flutter', 'dart']).run(); // any Iterable; stored as a private copy
await tags.add(1, 'hive').run(); // append; an absent key becomes [value]
await tags.remove(1, 'dart').run(); // first occurrence; an absent key is a no-op
final postTags = tags.getOr(1); // List<String>: unmodifiable view, empty when absent
final maybe = tags.get(1); // Option<List<String>>: None = absent, Some([]) = stored empty
Worth knowing:
- Lists you read out are unmodifiable views; lists you put in are copied. Mutating your
original afterwards never leaks into the box.
add/addAll/removeare read-modify-writes, O(n) in the stored list. - Absent isn't the same as empty.
getkeeps them apart;getOrfolds both to[]on purpose. - List semantics only: order preserved, duplicates allowed. Sets, maps, and nested collections of custom types stay out (the read-boundary cast can't fix inner reification). Model richer shapes as adapter-registered value types instead.
LazyIterableBoxis the same surface on the lazy axis.
๐ DualKeyBox
Two natural dimensions to your data (user + day, row + column). Address it by both parts, query by either.
Lookups, reverse queries, and the codec choice
final events = await DualKeyBox.open<Event, int, int>('user_events').run();
await events.put(userId, dayIndex, event).run();
final one = events.get(userId, dayIndex); // Option<Event>: exact lookups stay O(1)
final usersWeek = events.queryByPrimary(userId); // List<Event>: everything for this user
final everyoneToday = events.queryBySecondary(dayIndex); // List<Event>: everyone, this day
Queries hand back a plain (possibly empty) list, and in 1.0 they're an honest O(K) scan over
the live key set: free until you call one, exact when you do, documented instead of hidden. Where
a whole key is needed (keys, putAll, watch events) it travels as a (primary, secondary)
record. LazyDualKeyBox matches the surface on the lazy axis (queries return Task<List<T>>).
Both parts round-trip through one DualKeyCodec. (int, int) defaults to the safe
StringCompositeDualCodec (full-range parts, negatives included, no ceilings). If both parts fit
in 16 bits and the numbers matter to you, opt into PackedIntDualCodec
(codec: const PackedIntDualCodec()). It packs both parts into a single u32 key and is
bit-identical to the old 0.0.x .bitShift scheme, so those boxes read in place. The two
codecs trade off measurably; Performance has the head-to-head. Rolling your own
part types? Implement DualKeyCodec<K1, K2> and keep the encoding bijective, or reverse queries
will lie to you.
๐๏ธ Make it yours
The boxes do their work through seams you can swap. Every open (and every lazy constructor)
takes these, all optional:
codec:aKeyCodec<K>(orDualKeyCodec<K1, K2>) for key types beyondint/String.observer:aBoxObserverto hear semantic events. Silent when you pass nothing.cipher:ahive_ceHiveCipherfor an encrypted box.- hive pluggables (
keyComparator,compactionStrategy,crashRecovery), passed straight through to the engine.
๐๏ธ A custom key type
int and String work out of the box. Anything else brings a codec:
final class DateKeyCodec implements KeyCodec<DateTime> {
const DateKeyCodec();
@override
Object encode(DateTime key) => key.toIso8601String();
@override
DateTime decode(Object rawKey) => DateTime.parse(rawKey as String);
}
final byDay = await KeyedBox.open<Todo, DateTime>(
'by_day',
codec: const DateKeyCodec(),
).run();
encode has to produce an int in 0..0xFFFFFFFF or a String of at most 255 UTF-8 bytes.
That's hive's raw key domain, and staying inside it is what keeps your data safe (more on that
just below).
๐ฃ Observing what your code does
Pass an observer and you'll hear every semantic event. Pass none and dispatch costs nothing:
final todos = await KeyedBox.open<Todo, int>(
'todos',
observer: const PrintingBoxObserver(), // dart:developer log, DevTools-filterable
).run();
Extend BoxObserver and override only the events you care about; PrintingBoxObserver is the
ready-made sink. Two notes on the engine side: hive_ce's own warnings stay on its global logging
channel (its logging options filter them), and the
Hive Inspector DevTools extension is handy for eyeballing box
contents while your observer reports what the code did to them.
๐ Eager or lazy? (measured)
Folklore says "lazy opens instantly and saves all the memory." The maintainer benchmark (macOS Apple Silicon, AOT, hive_ce 2.19.3) disagrees:
- Opening costs the same either way, and it scales with file size: ~70 ms at 100K int-keyed entries, ~3 s at 1M. Hive parses every frame to build the keystore on any open. What lazy skips is holding onto the values.
- Keys always live in RAM, eager or lazy: ~21 to 33 MB at 100K entries, ~200 to 270 MB at 1M (String keys run 20 to 80% heavier than int keys).
- Reads are where they split. An eager get is ~0.5 to 0.8 ยตs from memory; a lazy get pays for a disk read at ~22 to 25 ยตs.
Open cost tracks file size on both axes (the two lines sit right on top of each other), while reads are where they part ways:


So reach for eager on hot, value-heavy-read boxes that fit comfortably in RAM, and lazy on value-heavy boxes you read only now and then. Neither opens "instantly" at scale, and keys are a RAM cost you pay regardless.
๐ก๏ธ Safer than raw hive, at raw-hive speed
Release-mode hive_ce takes an out-of-range int key or an oversized String key without
complaint, then corrupts quietly: keys wrap into other slots, and an oversized String key can make
the whole box file unreadable on its next open. This package rejects exactly those keys with an
ArgumentError at the call site, before anything reaches disk.
That safety is measured, not paid for in speed. The wrapper-overhead benchmark (AOT, faรงade vs raw
hive_ce) lands the typed surface at roughly 2.5% on eager gets, 1.5% on lazy gets, and 3% on
puts. Within noise of raw, with the whole no-null contract sitting on top.
โก Performance
Two dual-key codecs ship, and they trade off like this:
| Operation | packed int | String composite |
|---|---|---|
| eager get, 100K entries | 51 ms | 81 ms |
| box open (eager), 100K | 70 ms | 133 ms |
| putAll, 100K | 78 ms | 145 ms |
| full key scan (a query), 100K | 5.0 ms | 18.9 ms |
| keystore RSS after open, 100K | 33 MB | 61 MB |
| box file size, 100K | 1.9 MB | 2.6 MB |
| lazy get / single put | codec-indifferent (disk dominates) |
Medians from the maintainer benchmark (macOS Apple Silicon, AOT, constant 1-byte values to
isolate key cost). Web is unmeasured; its ordering is assumed to follow the VM.
StringCompositeDualCodec is the safe default; reach for PackedIntDualCodec when these wins
matter and both parts fit in 16 bits.
The table rows are the 100K slice of these curves; the gap widens as boxes grow:


๐บ๏ธ Roadmap
What's on the table for 1.x (and what will never land)
Additive candidates for 1.x, in no committed order:
- Indexed reverse queries for very large (>100K) datasets: an inverted-index strategy on
LazyDualKeyBox, swapping out the O(K) scan behind the same query methods. - IsolatedHive support behind the box-acquisition seam (
hive_ceitself recommends it for multi-isolate apps), plusBoxCollectionwrapping if there's demand. - A migration helper API (1.0 documents recipes in MIGRATION.md for now).
- Sets, maps, and nested collections on the value-codec seam (
IterableBoxis list-only today). - A consumer fakes package (in-memory faรงades for app tests) and Flutter companions (a
ValueListenableadapter), as separate packages so the core stays pure Dart.
Deliberately never wrapped, because they fight the typed, codec-addressed key model:
auto-increment add / addAll, index-based getAt / putAt / deleteAt / keyAt, toMap,
and valuesBetween. Reach for a raw hive_ce box where those genuinely fit.
Libraries
- hive_box_manager
- Typed, fpdart-first faรงades over
hive_ceboxes: nonulls, lazy effects, ready-made CRUD, purpose-built box variants.