GitHub - kanso-lang/kq: jq-style JSON queries, byte-identical to jq -S and faster on paths — written in kanso

GitHub

5 min read Original article ↗

A jq-style JSON query tool, written in kanso. Output is byte-identical to jq -S on every query below — verified with diff, not claimed.

Speed

Interleaved runs (kq and jq alternate, so machine state hits both alike), whole-process wall time (startup + read + parse + query + print), nine rounds per row with enough repetitions in each to amortise process startup, median of the nine. Byte-identity verified before any timing. Apple M-series, quiet box, 2026-08-09. The 1.9 MB fixture is ten flat copies of bench/large.json's elements, so it is reproducible from what the repo already carries.

kq against jq 1.7.1. Round-to-round spread was 3.7% on the path queries and 8.7% on the full print of the 1.9 MB document, so the last digit of each figure carries no information.

These rows used to be regenerated by CI on every pull request. Two consecutive runs of identical code moved every one of them by nine to twenty-two per cent and reversed the direction of the largest, because a shared runner is a different physical machine each time and alternating the two programs cannot cancel that. Interleaving handles the noise within a run, which is not where the error was.

workload kq jq 1.7.1
path query, 188 KB 2.8 ms 4.7 ms kq 1.7x faster
path query, 1.9 MB 11.8 ms 24.4 ms kq 2.1x faster
full pretty-print, 188 KB 5.2 ms 12.5 ms kq 2.4x faster
full pretty-print, 1.9 MB 147 ms 99.5 ms jq 1.5x faster

The large row used to read 1113 ms, ten times jq's. kanso #639 changed when the copy walk may share a node and made the walk quadratic in document size; the counter behind it read 2,721 on the 188 KB fixture and 286,401 on ten times the data, and now reads 69 and 717. bench/scale_gate.kso is the gate that refuses its return, and bench/scale_exceptions.txt is empty again.

Deterministic allocator counters, regenerated by CI on every commit. Every figure reproduces on any machine, and ratio is ten times the document over the 188 KB fixture — work that is linear in the input lands near 10.

counter 188 KB 2.1 MB ratio
allocs 98310 982696 9.9x
alloc_bytes 5757232 57492036 9.9x
arena_peak_bytes 3145728 26004240 8.2x
carry_dedup 69 717 10.3x
view_allocs 2761 27610 10.0x
view_frees 0 0
full pretty-print, 1.9 MB kq jq 1.7.1
wall clock, best of seven 140.06 ms 100.59 ms
peak footprint 28.8 MB 29.9 MB

Measured in one interleaved sitting on 2026-08-05, alternating the two binaries so neither gets a warm machine the other did not. kq holds less memory and takes about a third longer. Retired instructions and cycles are the honest way to say this without a clock in it, and they want a box that can read the hardware counters; they are not in the table rather than stale in it.

The machinery under those rows is kanso's, and it is worth a paragraph. The arena rewinds between loop iterations when the compiler proves the iteration keeps nothing across the line, and a byte accumulator earns that proof by pointer identity: it is the very object that arrived at the loop's entry, threaded through appends the uniqueness analysis showed in place, with its growth outside the arena where a rewind cannot reach. Every loop on kq's print path qualifies, so each element's temporaries die the moment the next element begins. That mechanism still holds; what regressed is a separate one, the walk that decides when a node may be shared rather than copied.

What remains of kq's footprint is the decoded document — live across the whole run by construction — plus the decode-phase transients the region holds until exit. The output no longer contributes: it streams.

Absolutes here carry the load; a quiet box brings every row down.

The path-query gap grows with document size: kq decodes, walks to the subtree, and prints only that — the win compounds as the part you didn't ask for gets bigger. Pretty-printing is jq's board at scale again while the copy-walk regression stands: the byte builder in the encode path (one accumulator threaded through the whole tree, escape scanning proven clean in one SIMD pass) still wins the smaller documents and loses the large ones to the walk.

One deliberate difference: on a path that doesn't exist, jq prints null; kq reports an error naming the missing key. kanso treats a missing index as a failure to surface, not a nothing to pass along — if you want jq's silence, query a path that exists. The race harness verifies byte-identity per query before timing anything, which is exactly how this difference was caught.

Use

kq <path> [file.json]        # or pipe json on stdin
kq .users[3].name data.json

Why it's fast

kq is ~400 lines of kanso sharing its decoder with the standard library — the same decoder that outruns hand-tuned serde_json on the language's json gauntlet. No hand-written parser tricks live in this directory; the speed is the compiler's. The story: kanso-lang.dev/compiler.html.

Specs

sh spec.sh (with KANSO pointing at a kanso build) runs the unit tests, then eleven fixture cases over non-trivial JSON — unicode/CJK/emoji and escapes, precision-edge numbers, deep nesting and empty containers, and the 188 KB nested document — each checked against a committed golden AND against live jq -S byte-for-byte. CI gates on all of it.

Intel macs: no GitHub runners exist for that target anymore; build from source (kanso build .) or use Rosetta until a cross-build lands.