>_ZDBVIEW
One binary for both halves of the cache. A SQLite database gets full generic CRUD โ browse tables, edit any cell, insert and delete rows, run raw SQL โ because SQLite is self-describing. An rkyv archive is not self-describing, so it gets decoded to real key/value records when its format is one zdbview recognizes, and a structural view โ info, embedded strings, hex โ when it is not. Which backend you get is decided by the file's header bytes, never by its name.
Quickstart
# install (binary + both man pages + zsh completion) brew tap MenkeTechnologies/menketech brew install zdbview # or from crates.io (binary only) cargo install zdbview # or build from source git clone https://github.com/MenkeTechnologies/zdbview cd zdbview && cargo build --release # open a database โ kind is detected from the header magic zdbview app.sqlite # SQLite โ full CRUD zdbview ~/.cache/zshrs/scripts.db # rkyv โ key/value records (despite the .db name) # force a backend zdbview --sqlite file zdbview --rkyv file # no argument โ pick from recently opened files zdbview
File type detection
The first 16 bytes are compared against the SQLite header magic SQLite format 3\0. When a full header is readable the magic decides alone โ its absence means the file opens in the binary inspector, whatever the extension says.
| Situation | Result |
|---|---|
| Header readable, magic present | SQLite backend |
| Header readable, magic absent | rkyv / binary inspector โ extension ignored |
| File shorter than 16 bytes | Extension tie-breaker: .db / .sqlite / .sqlite3 โ SQLite, else inspector |
--sqlite / --rkyv | Short-circuits both rules |
| Magic present but SQLite refuses the file | Falls back to the inspector instead of failing |
Trusting the magic over the name is the whole point: rkyv cache shards are routinely written under a .db name, and an extension-first reader hands those to SQLite, which then reports a corrupt database for a perfectly valid archive.
SQLite โ generic CRUD
The left pane lists tables and views from sqlite_master; the right pane shows one 500-row page of the selection. Every edit and delete addresses its row by rowid, selected alongside the real columns as SELECT rowid, * FROM <table> โ which is what makes the CRUD generic: no schema knowledge, no primary-key guessing, no per-database configuration.
| Key | Operation |
|---|---|
| e | Edit the selected cell. Enter runs UPDATE "<table>" SET "<col>" = ?1 WHERE rowid = ?2 with the text bound as a parameter, then reloads the page. |
| a | Insert one row with INSERT INTO <table> DEFAULT VALUES. |
| d | Delete the selected row after a y confirmation. |
| : | Run an arbitrary SQL statement; the affected-row count lands in the status line and the page reloads. |
Identifiers are double-quoted with internal quotes doubled, so table and column names containing spaces or keywords work. A WITHOUT ROWID table has no generic row handle, so its rows are listed read-only. Values display by type: integers and reals verbatim, text as UTF-8, NULL as NULL, blobs as <blob N bytes>.
Large databases
Nothing the grid does waits on a scan of the whole table. Measured on a 23 GB SQLite file with a 300 MB WAL, 6.5M rows in the table being browsed and a filter matching 270k of them โ development build on both sides, so the ratios are what to read:
| Operation | Before | Now |
|---|---|---|
| Open, first page drawn | โ | 4 ms |
| First page under a filter | 4.16 s | 28 ms |
| Step to the next / previous page | grows with the page number | 21 / 36 ms |
Last page (G) | 9.6 s | 1 ms |
| Exact row count behind a filter | 9.2 s, blocking | 1.6 s, in the background |
Pages, exact counts and n/N searches each run on a worker with its own read-only connection, so a scan cannot block the display โ and browsing a database another process is writing can never make zdbview checkpoint its WAL. Every request carries a generation, and each connection runs a progress handler that abandons its statement once that generation is stale, so typing a filter costs one query rather than one per keystroke.
The total is never scanned for: a page fetches one row more than it shows, which is all that is needed to know whether another page exists, and the title reads 501+ until the background count lands. Paging steps by cursor rather than by offset โ LIMIT n OFFSET k makes SQLite walk and discard k matching rows, 6.2 s at offset 269000 against 26 ms at the start โ and the last page is read backwards from the end. The exact count is taken on every core by cutting the table into rowid ranges; the cuts are arithmetic, because finding balanced ones means walking the whole index, which cost 5.8 s of cold reads on that file.
rkyv โ recognized key/value, or structural fallback
An rkyv archive is a memory image of a Rust value plus a root pointer. The format stores no field names, no type tags and no embedded schema โ the originating Rust type is the schema. zdbview closes that gap with a format registry: for archives it recognizes it carries a faithful, byte-compatible copy of the producer's archive type, detects the format by its magic header, validates with rkyv::check_archived_root, and decodes real key/value records. Anything it does not recognize falls back to the raw structural views.
| View | Shows |
|---|---|
| 0 โ Records | Key/value table for a recognized archive: keys on the left, the selected value's decoded scalar fields plus a hex dump of its blob on the right. Searchable by key. Selected automatically whenever a format is recognized. |
| 1 โ Info | Path and byte size, plus the detected format name and decoded header fields when recognized. |
| 2 โ Strings | Every run of โฅ 4 printable ASCII bytes with its hex offset โ in practice the keys, interned identifiers and string fields, in archive order. |
| 3 โ Hex | xxd-style dump: 16 bytes per line as offset hex |ascii|. |
Recognized formats โ every magic the registry knows, with the little-endian u32 as it appears in the header. The magic sits wherever rkyv placed the root, which is near the end of the file, not at offset 0:
| Format | Magic | u32 (LE) | Detected by | Record key |
|---|---|---|---|---|
| zshrs script cache | ZRSC | 0x5A525343 | magic | script path |
| zshrs autoload cache | ZRAL | 0x5A52414C | magic | function name |
| zshrs canonical shard | ZSHS | 0x5A534853 | magic, or a validated unstamped header (the recorder writes magic = 0) | section/key, section[i], extras/<sub>/<key> |
| zshrs system shard | ZSHS | 0x5A534853 | magic โ second layout under the same magic, a flat blob map | entry key |
| strykelang script cache | STRY | 0x53545259 | magic (native v4 + compat layout) | script path |
| awkrs script cache | AWKR | 0x41574B52 | magic | script path |
| vimlrs script cache | VIML | 0x56494D4C | magic | script path |
| elisprs heap-image cache | ELSP | 0x454C5350 | magic | script path |
| pythonrs bytecode cache | none | โ | validated try-decode | source path |
| rubylang / arb script cache | none | โ | validated try-decode | u64 content hash |
Magic-bearing formats are matched by header; the header-less hash-keyed shards are attempted last and gated by rkyv validation, so an unrelated archive falls through to the structural view instead of mis-decoding. Adding a format is a single registry entry: copy the producer's archive type โ same rkyv version (0.7) and features (validation, archive_le, size_32), same field order and types โ and map its magic, or add a validated try-decode for a header-less one.
The Records view is editable: a add, e edit, r rename, d delete. Each change deserializes the shard, mutates the owned value, re-serializes the whole archive and swaps it in atomically (temp + rename). Because the registry types are byte-compatible with the producer, the re-serialized archive is byte-identical to what the producing host would have written, so it keeps reading the shard normally โ verified by round-tripping every real cache. Unrecognized archives stay structural and read-only, because guessing field boundaries in an untyped image would produce plausible, wrong output.
Keys
| Key | Action |
|---|---|
| Tab | Switch focus between the table list and the row grid |
| j k / arrows | Move; ←/→ move the selected column in the row grid |
| gg / G | First / last โ across pages, not just the visible rows |
| Ctrl-f / Ctrl-b | Page forward / back (also PageDown / PageUp) |
| / | Filter the list as you type โ Enter keeps it, Esc clears it |
| n / N | Next / previous match โ in the displayed order, so sorting and the active filter are both respected |
| e a d | Edit cell / insert row / delete row (SQLite) โ a blob cell opens in the hex editor, since bytes have no text form; rows are addressed by rowid or, in a WITHOUT ROWID table, by primary key |
| E | Edit the cell as bytes whatever it holds โ the only way to put binary into a cell that is not already a blob (SQLite) |
| : | SQL editor โ multi-line, Tab completion, transcript, history, per-statement timing, Alt-e/F5 for the query plan, and the shell's dot-commands (SQLite) |
| s | Sort by the column under the cursor: ascending → descending → off (SQLite) |
| < / > | Move the sort to the previous / next column, keeping the direction |
| S | Schema view for the selected table (SQLite) |
| D | Database report โ .dbinfo pragmas and object counts, then i integrity check, Q quick check, f foreign-key lint, v/z/r VACUUM/ANALYZE/REINDEX (SQLite) |
| F | Follow the foreign key under the cursor to the row it references (SQLite) |
| A | Column statistics โ rows, nulls, distinct, numeric cells, longest, min/max, mean; Enter for a column's frequency table (SQLite) |
| Y | Copy the selected row as an INSERT statement (SQLite) |
| 0 1 2 3 | Records / Info / Strings / Hex (rkyv โ 0 only when the format was recognized) |
| a e r d | Create / hex-edit value / rename key / delete record (rkyv Records) |
| i Tab o x ^s | Inside the hex editor: EDIT mode / hex↔ascii column / insert / delete / save |
| Enter | Open the detail screen for the selected row or record; from the table list, focus the rows; in the picker, open the file |
| v | Cycle how a value renders on the detail screen: auto / hex / text / disasm |
| y | Copy the cell, value or record key to the clipboard (OSC 52 โ works over SSH) |
| x | Export the current table as CSV, or all records as JSON, to a file |
| w | Write monitor โ live writes to every known shard and database, with the selected database's WAL tail in a second frame (also from the picker); t swaps that frame for bytes written per table, F walks the log |
| o | Back to the file list โ open another file without restarting |
| c | Color-scheme chooser โ live preview, Enter saves, Esc restores |
| C | Palette editor for the six base colors |
| h / ? | Help overlay; any key closes it |
| q / Esc | Leave the current screen, or quit โ inside a modal, Esc cancels the modal only |
Search
/ takes a plain substring (not a regex); n and N then walk the matches. What is scanned depends on the mode and, in SQLite, on which pane has focus.
| Mode | Scanned |
|---|---|
| SQLite โ tables | Table names, case-insensitive, wrapping |
| SQLite โ rows | The whole table, not just the loaded page: a SELECT rowid โฆ WHERE <any column> LIKE ? that walks from the current row and wraps from the far edge, then pages to the hit |
| rkyv โ Records | Record keys, case-insensitive, wrapping |
| rkyv โ Strings | The extracted runs, case-insensitive, wrapping |
| rkyv โ Hex | Raw bytes, case-sensitive, does not wrap; the view scrolls to the hit and the offset is reported |
| Picker | Recorded file paths, case-insensitive, wrapping |
Detail view
Enter on a row or record opens a full-screen detail: every field untruncated at the top, and a scrollable value pane below. v cycles how the value bytes render โ auto (text when it looks textual, else hex), hex (xxd style), or text (UTF-8). y copies the value. This is how you read a cell or record blob too wide for the grid.
Export
Interactive x writes the current table (CSV) or all decoded records (JSON) to a file in the working directory. Non-interactively, --export takes the sqlite3 shell's output modes and --table restricts it to one table, which is what makes zdbview scriptable:
zdbview data.db --export json # { table: [ {col: val, โฆ}, โฆ ] } for every table
zdbview data.db --export csv # the first table as RFC-4180 CSV
zdbview data.db --export tsv # .mode tabs โ tabs and newlines escaped, never raw
zdbview data.db --export markdown # .mode markdown โ padded GitHub table
zdbview data.db --export line # .mode line โ one column per line
zdbview data.db --export insert # .mode insert โ one INSERT per row
zdbview data.db --export sql # .dump โ schema plus data, replayable
zdbview data.db --export csv --table users # just that table
zdbview cache.rkyv --export json # decoded records; each value blob as lowercase hex
CSV and JSON are emitted by a dependency-free writer in export.rs โ quoting and escaping are covered by unit tests. --export never starts the TUI, so it is safe in a pipeline.
--export sql is .dump, and it โ like insert mode โ reads values from the database rather than from the grid, so a blob comes out as x'โฆ' and a real keeps its decimal point. The display string for a blob is a description of it (<blob 2 bytes>), which is useless in SQL, so everything that writes SQL uses the exact form. Virtual tables are the case that makes a naive dump unreplayable: CREATE VIRTUAL TABLE runs the module's constructor and builds the shadow tables, which the dump would then create a second time. zdbview does what the shell does โ writes the sqlite_schema row directly under PRAGMA writable_schema=ON and emits the shadow tables with CREATE TABLE IF NOT EXISTS โ so an FTS5 index survives the round trip and still answers MATCH. Checked line-for-line against sqlite3 .dump: identical content, differing only in the order sqlite_master is walked.
--backup FILE copies a database with VACUUM INTO (the shell's .backup), the one consistent way to copy a file that may have writers. An existing target is an error, not an overwrite.
Which table is being written, live
In the write monitor, t swaps the bottom frame for a per-table breakdown of the bytes written since it opened. This is the thing no other SQLite tool shows: not how big a table is, but which one is being written to right now.
It works because a WAL frame header carries the page number it rewrote, and a page can be traced to the b-tree that owns it. Every table's and index's root page is walked straight out of the file to build a page โ owner map. The file alone is the past in WAL mode โ a table created but not yet checkpointed is not even in its schema โ so the log's newest image of each page is applied over the file before the walk; without that, every page reads as unmapped. Each sample reads only the frame headers written since the last one, so the cost does not grow with the log's length, and the map is re-read when a checkpoint restarts the log or a frame names a page it does not cover.
written is the total since the monitor opened; rate is what the last sample attributed, so a table that has stopped reads as idle while its total stays โ that pair is what answers "which table is being written fastest right now".
Indexes are named as indexes, and a page the map cannot place is shown as page N (unmapped) rather than being charged to a table it might not belong to. A database in rollback-journal mode gets no breakdown: a journal records the pages it is about to change, not the ones it did.
Walking the log
F in the monitor opens the log as a history. A frame does not merely say that a page changed โ it carries the whole page image, so the rows that write put there are decoded and shown beside the frame list, labelled with the table's column names. j/k step one frame (down is back in time), [ and ] jump a whole transaction, g/G reach the newest and oldest frame, and / filters the log by table, page number or commit โ stepping then walks only what is listed, which is what makes a log of tens of thousands of frames usable.
A value that continues onto an overflow page says so rather than showing a truncated string โ the rest is in a different frame โ and a page that is not a table leaf (interior node, index page, overflow) is named as what it is instead of pretending to hold rows. This is the only view that reads frame payloads, so it decodes one frame at a time on a keypress: a 190 MB log holds tens of thousands of frames and each payload is a whole page.
Dot-commands in the editor
A line starting with . is a dot-command, as in sqlite3; Tab completes them and .help lists what is implemented: .tables, .schema, .indexes, .dump, .databases, .attach/.detach, .mode, .headers, .output/.once, .timer, .eqp, .import, .read, .backup, .expert, .vacuum/.analyze/.reindex, .quit. .mode renders a result set through the same writers --export uses; list is the grid the editor draws.
.expert is not sqlite3_expert โ rusqlite exposes no binding for it. It reads the plan the planner actually produced plus the columns the statement compares on, and names an index for each table chosen for a full scan. A column is attributed to a table only when no other table in the statement has one by that name, so an ambiguous join reports the scan without guessing.
Following a foreign key
F on a foreign-key column jumps to the row it references โ the navigation a schema gives you for free, and what DB Browser and Datasette both link. The parent column comes from PRAGMA foreign_key_list, resolved to the parent's primary key when the pragma leaves it empty; a dangling key, a NULL key and a non-key column are each reported rather than followed.
Column statistics and frequency
A describes every column of the current table โ rows, nulls, distinct values, how many cells SQLite actually stores as numbers, the longest value, the extremes, and the mean of the numeric cells. That numeric count is the only place a column declared INTEGER but holding text shows up, because a declared type in SQLite is an affinity hint rather than a constraint. Enter on a column adds its most common values with bars, so a glance says whether the column is skewed.
Every column costs one pass over the table, and a wide blob column costs more than a second of it on a real database โ 1.6s for count(DISTINCT body) across 46,716 rows of a 196 MB cache. The pass therefore runs on its own connection on a background thread: the screen opens saying analyzing โฆ and fills in when the work lands, so the UI never blocks.
Import
--import rows.csv --table people loads a CSV (or TSV, chosen by extension) into an existing table โ the shell's .import. The header row names the columns, so a file ordered differently from the table still lands correctly, and a header naming a column the table does not have is an error rather than a silent drop. The whole file is one transaction, so a row with the wrong field count rolls all of it back. The reader is RFC 4180 by hand, like the writers: quoted fields may hold the separator, newlines and doubled quotes, and CRLF or a missing final newline are both accepted.
Recovery
--recover (and .recover in the editor) salvages what a file still holds by reading pages โ it never opens the database, so it works on a file SQLite refuses, and it walks every page rather than only those reachable from sqlite_master, which is what brings back rows a corrupt b-tree root has orphaned.
Measured against a 400-row database with its table root zeroed โ SQLite answers database disk image is malformed โ sqlite3 .recover emits 400 inserts and so does this, and the replayed script holds data identical to the original. Truncated to 20 of 35 pages, both recover the same 226 rows. A 20 KB value spanning overflow pages comes back whole.
Rows on pages that cannot be attributed to a table go to a lost_and_found table carrying the page each came from, and every assumption the pass made is written into the script as a comment. Indexes are not recovered โ the script replays their CREATE INDEX statements โ and the file is never modified.
A WITHOUT ROWID table keeps its rows in an index b-tree, so three page kinds are read, not one: table leaves, index leaves (where the key record is the row) and index interiors, which unlike table interiors carry key payloads of their own. Index cells also keep far less of their payload on the page than table cells, and the two size formulas are not interchangeable. Measured on a 300-row keyed table with its root zeroed: sqlite3 .recover brings back 298 rows and so does this, replaying to key-0000โฆkey-0299.
Clipboard
y copies through the OSC 52 terminal escape, written straight to /dev/tty. That means no clipboard library, no X/Wayland/pbcopy dependency, and it works over SSH โ the terminal emulator on your side sets the clipboard. It is best-effort: with no controlling terminal (output redirected), the copy silently does nothing.
Recent files
Every successful open is recorded to $XDG_CACHE_HOME/zdbview/recent (or ~/.cache/zdbview/recent), one tab-separated line per entry: unix-seconds kind absolute-path. Paths are canonicalized, so the same file reached by different relative paths or symlinks dedupes to a single entry that moves back to the front on re-open. The list is capped at 50 and written via temp-file-plus-rename, so a concurrent reader never sees a half-written list. The recorded kind is the one actually used โ a .db file that turned out to be an archive is remembered as rkyv.
CLI reference
| Flag | Description |
|---|---|
| [FILE] | File to open; with none, the recent-files picker opens |
| --sqlite | Force the SQLite backend |
| --rkyv | Force the rkyv / binary inspector (conflicts with --sqlite) |
| --export FORMAT | Dump the file to stdout and exit โ json, csv, tsv, markdown, insert, line or sql; requires a file argument |
| --table TABLE | Restrict --export to one table |
| --backup FILE | Copy the database with VACUUM INTO and exit |
| --import FILE | Load a CSV (or TSV, by extension) into --table and exit |
| --recover | Salvage rows page by page to stdout as SQL, without opening the database |
| -h, --help | Print usage |
| -V, --version | Print the version |
Man pages ship in the repo: man/man1/zdbview.1 and the all-in-one man/man1/zdbviewall.1. The zsh completion is completions/_zdbview.
Repository & links
- Source โ GitHub repo
- Crate โ crates.io (
cargo install zdbview), API docs on docs.rs - Engineering report โ module map, dependency footprint, verification
- Issues โ issue tracker
- Full README โ README on GitHub
- Format reference โ the rkyv archive format, the reason the rkyv side is read-only