Zenable's CLI can run as an agentic IDE hook handler on every tool use, prompt submit, and session stop. A typical coding session fires hundreds of invocations, each opening and closing local databases for checkpoint state, guardrails cache, and OAuth tokens. Our workloads are read-heavy, our runtimes are independent short-lived processes (not a daemon), and everything is local to the developer's machine to maximize CPU cache hits and minimize guardrails overhead.
We recently migrated all three databases from SQLite to bbolt. Here's why, what we evaluated, and the performance we measured.
What we evaluated
We looked at six embedded storage options for Go before landing on bbolt.
| Engine | Pure Go | Single file | Fit for short-lived CLI | Why we passed |
|---|---|---|---|---|
| bbolt | Yes | Yes | Excellent | (selected) |
| modernc.org/sqlite | Yes | Yes (+ WAL/SHM) | Good | SQL parsing overhead for KV workloads; WAL companion files; lock contention requiring retry logic |
| BadgerDB | Yes | No (directory of SSTs + vlogs) | Poor | Spawns background compaction goroutines; directory-level lock blocks concurrent hooks; designed for long-running servers |
| BuntDB | Yes | Yes (AOF) | Moderate | Full in-memory replay on every open; maintenance stalled (no commits since March 2024) |
| Pebble | CGo recommended | No (LSM directory) | Poor | CockroachDB's storage engine; multi-file, high memory baseline; scoped to server workloads |
| DuckDB | CGo required | Yes | Poor | OLAP columnar engine; complete category mismatch for KV operations |
The decision came down to our constraints: pure Go (no CGo, clean cross-compilation), single file per database (no companion files to orphan or corrupt), and minimal overhead for open-close-per-invocation patterns. bbolt was the only candidate that checked every box.
Why not SQLite
SQLite is a great database. We weren't leaving because it's bad; we were leaving because our access patterns don't need it. All three databases do the same thing: put a key, get a key, prefix scan, delete stale entries. No JOINs, no indexes beyond the primary key, no relational queries. SQL parsing and query planning is overhead we pay on every operation for features we never use.
The operational pain was more concrete. SQLite in WAL mode creates -wal and -shm companion files. When hook processes crash (which happens when IDEs kill subprocesses) those files can get orphaned and corrupt state. We'd built retry/backoff infrastructure (busy_timeout, isSQLiteLocked, cacheRetryConfig) specifically to handle concurrent hook processes fighting over the same database. bbolt's file-level locking with a simple timeout eliminates all of that.
Since our CLI doesn't run as a service or daemon, connection pooling is only useful within a single short-lived invocation. Both SQLite and bbolt face the same constraint: open the file, do the work, close it. No persistent pool survives across hook invocations. This levels the playing field and lets the raw operation performance speak.
Benchmark results
All benchmarks simulate the production pattern: open database, perform operation, close database. No connection pooling, no persistent handles. Exactly how the CLI runs as a hook subprocess.
| Operation | bbolt | SQLite | Speedup | Allocations |
|---|---|---|---|---|
| Open + close | 25 us | 79 us | 3.2x | 21 vs 52 (60% fewer) |
| Single key write | 51 us | 352 us | 6.9x | 71 vs 75 |
| Single key read | 27 us | 90 us | 3.3x | 30 vs 74 (59% fewer) |
| Write + read round trip | 53 us | 386 us | 7.3x | 80 vs 97 |
| Prefix scan (20 entries) | 20 us | 127 us | 6.4x | 19 vs 339 (94% fewer) |
| Concurrent reads (14 threads) | 15 us | 85 us | 5.7x | 18 vs 74 (76% fewer) |
bbolt is faster across every operation. The prefix scan result is notable: 94% fewer allocations, which directly reduces GC pressure in a process that starts and stops hundreds of times per session.
When tuning for the performance characteristics we were looking for, we found that by accepting a slight integrity tradeoff during catastrophic failures (process crash mid-write), we could skip fdatasync() on each commit. Since all three databases are transient caches that rebuild on next run, this tradeoff carries very little practical risk for our use case and accounts for the bulk of the write speedup (6.9x for single writes, 7.3x for round trips).
Migration
The new version seamlessly migrates from SQLite with no interaction needed. On first run after upgrade, the CLI detects existing SQLite files by magic bytes, reads the data, writes it to a new bbolt database, and cleans up.
What this means in practice
A typical coding session generates 80-100 database operations across hook invocations, roughly 80% reads. Faster hook response times, zero lock contention errors, no orphaned WAL files, and one less thing between the developer and their coding agent.
This is the storage layer half of the story. We also optimized guardrail runtime batching from groups of 5 to all-at-once (58x faster), moved guardrails to local sync and on-device execution for 200x faster checks, shipped 3.4x faster AI reviews with 9.6x more consistent latency, and rewrote the CLI as a native Go binary that set the stage for this bbolt migration.