BigJSON: A Viewer That Opens
Gigabytes of JSON Without Loading Them
No open-source viewer on my Mac could open the multi-gigabyte JSON files I worked with, so I built one. A read-only desktop viewer and a SQL-shaped query CLI, over a Rust engine that never loads the file into memory.
I kept hitting the same wall: I had JSON files several gigabytes in size, and nothing on my Mac could just open them. IDEs choke on them, and when one does limp a large file open, all it gives you is read-only plain text: no structure, no navigation, no search worth the name. The dedicated viewers people point to are commercial; the most-cited one, Dadroit, caps its free tier at around 50 MB, which is nothing next to a multi-gigabyte dump.
So I built BigJSON: a free, open-source, cross-platform JSON viewer that opens multi-GB files, lets you browse the structure, search and query it, and export the results. It’s read-only, fast, and not bound by file size. A second tool fell out of the same core: jsq, a SQL-shaped command-line query language for those same files.
Both are thin frontends over one Rust engine, and the engine has a single trick that makes all of it possible: it never loads the file into memory. Here’s a number that captures the wall everything else hits. Run jq over a 1 GB file to count a few matching rows and it eats roughly 17 GB of RAM, because it first parses the whole document into a heap tree. That same model is why IDEs and viewers fall over on large files. BigJSON’s engine memory-maps the file and builds a compact index instead, so it opens a gigabyte while holding tens of megabytes. This is how.
The Problem
Why a Gigabyte of JSON Costs You Sixteen
jq is a per-token stream processor, but to evaluate a filter it first materializes the document into an in-memory tree: every object becomes a hashmap, every array a vector, every number and string a heap-allocated, reference-counted value. That representation is convenient (you can walk it, reshape it, index into it freely) but it’s bulky. A JSON number that occupies three bytes on disk becomes a tagged, boxed value many times that size once it’s a node in a tree.
Measured end to end, the parsed form lands at roughly 16× the file size. A 1 GB file becomes ~16 GB of live heap, and that holds whether you use jq (C) or jaq (a tight Rust rewrite). The language and the implementation language don’t matter here. The model does.
The approach jq, jaq, and most JSON libraries take: read the whole document and build a tree of boxed values in memory before any query runs. Convenient for arbitrary reshaping, but peak memory scales with the input size, not the output size, so a query that returns one number off a 1 GB file still pays for 1 GB (×16) of resident tree.
For range scans and projections that emit a row per input element, holding everything is sometimes unavoidable. But the queries that dominate real data work (count the paid orders, sum revenue per region, join events to users and total by tier) collapse millions of rows down to a handful. Paying gigabytes to produce three group rows is pure overhead. That’s the gap jsq goes after.
The Bet
One Query, One Streaming Pass
The design premise of the query language is narrow on purpose: you don’t get a Turing-complete transformation language. You get one query, shaped like SQL, that runs in a single pass and prints NDJSON. It’s what the CLI speaks, and it’s the language behind the viewer’s query bar.
# Count the paid orders
jsq orders.json 'from .orders[] as o where o.status == "paid" aggregate { n: count() }'
# Revenue per region, top 5
jsq orders.json 'from .orders[] as o
aggregate { revenue: sum(o.total) } by o.region
order by .revenue desc
limit 5'Every query emits one JSON value per line, so the output still composes with the tools you already use: jsq ... | jq ..., | head, | wc -l. jsq doesn’t replace jq; it sits upstream of it, doing the heavy GB-scale reduction that jq would choke on, and hands the small result downstream.
The grammar is a fixed clause pipeline. Clauses are optional except from, but their order is fixed, which is exactly what makes single-pass evaluation tractable:
from PATH as ALIAS (required) → join ... on L == R → unnest EXPR as ALIAS → where PREDICATE → let bindings → distinct → aggregate { ... } by KEY → having PREDICATE → select { ... } → order by EXPR → limit N. Paths use .field, [N], and [] to iterate; there’s no .[] or *. The whole reference, with worked examples for every clause, lives in docs/QUERY.md.
If you think in SQL, pandas, or list comprehensions, the mapping is direct: for x in arr is from .arr[] as x, filter is where, groupBy + reduce is aggregate ... by, flatMap is unnest, DISTINCT ON is distinct by. The point of constraining the language is that every one of those clauses can be evaluated as the stream flows past, without ever holding the stream.
The Architecture
Two Frontends, One Engine
The primary product is BigJSON, a cross-platform desktop viewer (Tauri + Svelte). It opens a multi-GB file behind a streaming progress bar, then lets you explore it read-only: a collapsible tree navigator with breadcrumbs, text search across the document, an in-app query bar running the same jsq language, and CSV/JSON export of results. Rows are virtualized, so scrolling a gigabyte stays smooth; the UI only ever holds the handful of rows actually on screen.
The jsq CLI is the secondary frontend, a thin Rust binary that pipes NDJSON for shell composition. Both delegate every semantic decision and every byte of output to the same Rust engine crate.
That parity is a deliberate constraint, not an accident. Because the desktop app links the engine as a normal Rust crate rather than reimplementing anything in TypeScript, there is exactly one parser, one evaluator, one renderer. The viewer’s tree navigator is the clearest payoff: it never holds the JSON tree in JavaScript. It asks the engine for one level of children at a time over the memory-mapped index (a batched call into the engine’s child-iteration API), so expanding a node in a 10 GB file is a few page faults, not a load. And a query behaves identically in the CLI and the UI because it is literally the same code path.
The Query Path
From Text to NDJSON
A query string becomes results through four stages. The split between a surface AST and an engine AST is the load-bearing decision here: the surface form mirrors the SQL-shaped syntax a human writes, and a lowering pass rewrites it into a smaller set of primitive operators the evaluator knows how to stream.
Lowering is where the syntactic sugar dissolves. An aggregate item like revenue: sum(o.total) * 1.1 isn’t a single operation: the lowerer detects the sum(...) reducer buried inside the arithmetic, hoists it into a dedicated reduction slot, and rewrites the output expression to multiply that slot by 1.1. Field-set macros expand, let bindings get substituted into the aggregate block, and from .events[] becomes a primitive iterate-over-children operator. By the time the evaluator runs, there’s no SQL left, just a tree of stream operators.
The evaluator itself is push-based. Instead of pulling values through a chain of iterators, walk_eval dispatches on the operator and calls a sink closure for each value it emits:
fn walk_eval(
// ...
sink: &mut dyn FnMut(Value) -> bool,
) { /* ... */ }Each operator drives values downstream by calling a sink, rather than being pulled by a consumer. The sink returns a bool; returning false means “stop, I have enough.” This gives early termination for free: once a limit 5 has seen its fifth row, the signal propagates back up and the source stops scanning. Stateful operators (reducers, sort, distinct) intercept the stream and buffer only what they must.
That early-termination signal matters more than it looks. A limit after a sort doesn’t just trim the output; it bounds how much the sort has to keep (more on that below), and a limit over a plain scan stops the file walk the instant the quota is met.
The Memory Model
Memory-Mapped, Not Heap-Allocated
Here is the core trick, and it’s the whole reason the memory numbers look the way they do. jsq never reads the JSON file into a buffer. It memory-maps it, and it builds an offset index (a sidecar file) that it also memory-maps.
mmap maps a file’s bytes into the process’s address space without copying them into the heap. The pages are clean and file-backed: the kernel pages them in on demand as you touch them, and can drop them again under memory pressure without swapping. jsq parses the JSON once into a .jsonidx sidecar (a flat array of fixed-size records describing each node’s position) and memory-maps that too. Both the source and the index are reclaimable; neither lives on the heap.
The consequence is that the engine’s owned memory tracks the working set, not the file size. Scanning the gigabyte to compute a sum touches the index records sequentially, the kernel keeps a small window of pages resident, and the resident footprint stays flat. It also means the engine can work on files larger than physical RAM, something the parse-everything approach simply cannot do, because you can’t put 64 GB of parsed tree into 32 GB of heap.
This is also what makes the viewer feel instant: opening a file maps it and builds the index rather than reading it into a buffer, and scrolling pulls only the visible rows’ records off the mmap. BigJSON opens files that wouldn’t fit in RAM at all, which is exactly the case where IDEs give up.
This does produce a measurement subtlety worth being honest about:
jq and jaq the two numbers are nearly identical, because it’s all dirty heap. The gap between RSS and footprint is the streaming architecture showing up in the numbers.The Index
A Pre-Order Array of Records
The sidecar isn’t a tree of pointers; it’s a flat array of fixed-size records in pre-order (depth-first, parent before children). Each record is 48 bytes:
#[repr(C)]
pub struct NodeRecord {
offset: u64, // byte position in the source mmap
length: u64, // source byte span of the value
key_or_index: u64, // object: offset into the keys arena; array: slot index
parent: u32,
subtree_size: u32, // records in this subtree, including self (always >= 1)
child_count: u32, // direct children, primitives included
key_length: u32,
kind: u8, // Null | Bool | Number | String | Array | Object
flags: u8,
} // + 6 bytes padding -> 48, 8-byte aligned
Two invariants fall out of the pre-order layout, and the rest of the engine leans on both.
For any record at index k with subtree_size = n, the records [k+1 .. k+n] are exactly the descendants of k, in source order. A whole subtree is therefore a contiguous slice (cache-friendly to scan) and the next sibling of k sits at k + subtree_size. No next_sibling or child-list pointers are stored; they’re arithmetic. (Debug builds re-verify this invariant after every parse.)
The second decision is what gets a record at all. Emitting one record per JSON value would make the index enormous and mostly redundant: a flat object of ten small fields would cost ten records plus itself. So jsq uses a hybrid emit-gate:
Records are emitted only for containers (objects and arrays) and for strings whose source span is 256 bytes or longer (FAT_STRING_THRESHOLD). Small primitives (numbers, booleans, nulls, short strings) get no record. They live only in the source bytes and are reconstructed on demand: when iterating a container’s children, the evaluator walks the container’s source span in lockstep with the chain of skippable records, parsing inline primitives as it goes. Fewer records, a smaller index, and the common case (objects full of small scalars) stays cheap.
So child_count and subtree_size measure genuinely different things, and the struct keeps both: a container can have ten direct children but a subtree_size that only counts the nested containers among them, because the scalar children never became records.
Joins
Joining Without Materializing
A join is the classic way to blow up memory: the naive version materializes the cross product. jsq doesn’t. When a query joins on u.user_id == e.user_id, the engine builds a foreign-key index: a hashmap from each key value to the record IDs that carry it.
from .events[] as e
join .users[] as u on u.user_id == e.user_id
aggregate { revenue: sum(e.amount) } by u.tier
order by .tierInternally that index is a HashMap<ScalarKey, Vec<u32>>, built once per join and cached on the document. ScalarKey is a compact, hashable encoding of a JSON scalar (distinguishing integral from fractional numbers, interning the variants) so grouping and lookups never allocate a string per row. At runtime each event hashes its key once and gets the matching user records in $O(1)$, with no nested-loop scan and no joined rows held in memory.
This is why even a two-hop join (event → user → region) stays flat: it’s two index lookups per event, both $O(1)$, neither materializing anything. In the benchmarks the chained join over 7 million events holds the same ~36 MiB as a bare count.
Folding the Stream
Why Reductions Stay Flat
The reason aggregates cost almost nothing is that they fold the stream away as it flows. A sum ... by region keeps one accumulator per distinct region (a handful of entries) and updates them row by row. Millions of input rows in, three group rows out, and only the three group rows are ever resident.
Sorting is the operation that looks like it must buffer everything, and jsq avoids it whenever a limit follows:
order by ... limit N doesn’t buffer the whole stream and sort it. It keeps a bounded max-heap of size $N$ whose root is the worst surviving row. Each incoming row is compared against the root; if it’s better, it displaces the root, otherwise it’s discarded. Memory is $O(N)$ regardless of stream length, and the cost is $O(M \log N)$ for $M$ rows instead of $O(M \log M)$.
distinct dedupes against a set of value fingerprints rather than retaining full rows. The recurring theme: every stateful operator buffers only what its output demands, never the input.
Which sets up the one honest exception:
from .events[] as e where e.amount > 500 select { id, amount } emits one row per surviving event (about 3.5 million of them at 1 GB) and jsq buffers the whole result set before printing. That query costs ~1.2 GB, and it scales with the output size, not the file size. So jsq wins big on filters and aggregates that return little; for queries that return a lot of rows, every tool pays real memory.The Numbers
jsq vs jq vs jaq
The benchmark suite runs nine queries (filter/project, counts, single- and multi-metric group-bys, single and chained joins, unnest, post-aggregate having, and a combined kitchen-sink) across 10 MB, 100 MB, and 1 GB files, against jq 1.7.1 and jaq 3.0.0. Including jaq is the point: it isolates the variable. jq vs jaq is C vs Rust; jsq vs both is parse-everything vs streaming.
Here is the group-and-sum query (q3) at all three sizes:
| File | jsq time | jq time | jaq time | jsq RAM | jq RAM | jaq RAM |
|---|---|---|---|---|---|---|
| 10 MB | 0.26 s | 0.45 s | 0.24 s | 30 MiB | 167 MiB | 154 MiB |
| 100 MB | 0.60 s | 3.81 s | 1.77 s | 33 MiB | 1.7 GB | 1.5 GB |
| 1 GB | 7.0 s | 69 s | 28 s | 34 MiB | 17 GB | 15 GB |
At 1 GB jsq is ~10× faster than jq and ~4× faster than jaq on this query, and holds 34 MiB against 15-17 GB, a ~450-500× memory gap. The telling detail is the jaq column: rewriting jq in Rust buys a real speedup (~1.5-2×) but moves the memory needle essentially zero, because jaq still parses everything. The frugality comes from the architecture, not the language.
jq and jaq are competitive or faster on simple queries: the whole job finishes before jsq’s index build pays off, and they bring a far richer transformation language. Reach for jsq when files are large and the query reduces; reach for jq/jaq for quick hits on small files and arbitrary reshaping. The benchmark doc says so directly, rather than stacking the deck.The Trade-offs
What jsq Gives Up
The design comes with three real costs.
jsq runs one fixed-shape query. jq is Turing-complete and can express arbitrary transformations jsq simply can’t. The constraint is what makes single-pass streaming evaluation possible, but it is a constraint.
The first query on a file pays to parse it into the sidecar. On small files that cost dominates, which is exactly why jq/jaq win there. The investment amortizes over large files and repeated queries in a desktop session.
The flat-memory guarantee is for reducing queries. A query that emits a row per input buffers its whole result set. jsq’s edge is on the filter-and-aggregate workloads that dominate analytics, not on full-document reshaping.
The Memory Win Is the Architecture
The thing I keep coming back to is the jaq result. A meticulous Rust rewrite of jq runs faster but needs the same 15 GB, because it makes the same bet: parse the whole document, then query it. jsq makes the opposite bet (memory-map the file, build a compact pre-order index, and fold the query into the stream as it scans) and the payoff is a 450× memory reduction that a faster language could never buy.
That’s the whole lesson. When a workload is dominated by reduction, counting, summing, grouping, joining millions of rows down to a few, the question isn’t “how fast can I parse this?” It’s “do I have to parse all of it at all?” The answer is no, and tens of megabytes is what that answer costs.
And the original goal, opening a multi-gigabyte JSON file on a laptop and actually exploring it, turns out to be the same problem in different clothes. A viewer that scrolls a 10 GB file and a CLI that sums it in 34 MiB both come down to one decision: never load what you don’t need.