- Never panics. fastjq must never panic regardless of input — valid JSON, malformed JSON, empty input, or arbitrary bytes. A panic in a log processing pipeline is worse than wrong output. This guarantee is enforced by
TestNoPanicMalformedInput(deterministic) and three fuzz test functions (FuzzCompile,FuzzRunFixed,FuzzBoth). Any code path that could panic on bad input is a bug. - Correct results only guaranteed for valid JSON. With malformed input the output is undefined, but the process remains safe.
The governing principle: allocations are proportional to what you ask for, never to what the engine scans.
fastjq distinguishes four tiers of allocation behaviour:
| Tier | Rule | Examples |
|---|---|---|
| Tier 0 | 0 allocs — core processing hot path | access, filter, compare, arithmetic, construction, math |
| Tier 1 | Allocs ∝ output size — unavoidable API constraints | @base64, @uri, match, capture, scan, gsub |
| Tier 2 | Allocs ∝ output count — bounded by what was requested | range(n) (1 alloc/value); sort, group_by, unique (planned) |
| Tier 3 | Deferred — broader runtime integration outside the pure library core | full decimal semantics / module-import helpers / host-boundary helpers |
When deciding whether to implement a new operation, the question is always: does the allocation scale with the result, or with the input? If the caller can control the allocation by choosing what to request, it is acceptable. If the allocation scales with the shape of the data being processed regardless of the query, it is rejected.
Field access, filtering, comparison, arithmetic, construction, map(.field), math builtins, test(re), string operations, and most other operations achieve 0 allocs/op at steady state when using RunWithBuffer or RunFunc with a reused buffer.
This is the hot path for log processing. Any regression to non-zero allocs on these operations is a bug.
Implementation rules:
RunWithBuffer/RunFuncmust achieve 0 allocs for all Tier 0 operations- Static error sentinels (
errors.New) on hot-path functions —fmt.Errorfwith dynamic args poisons escape analysis - No
interface{},map[string]interface{}, or any Go type conversion — operate entirely on raw[]byte - No data copying except into the output buffer
Some operations must allocate to produce their result. The allocation is proportional to the output, not the input being scanned. Even gojq allocates thousands of times just to begin; these ops allocate a handful of times for specific results.
| Operation | Allocs | Reason |
|---|---|---|
@base64, @uri, @format "...\(...)" |
~4 | String-escape decoding before encoding |
match(re), capture(re) |
1 on hit, 0 on miss | Go's FindSubmatchIndex []int (unavoidable API) |
scan(re), gsub(re) |
∝ match count | Multi-result output |
sub(re) |
1 on hit | FindIndex []int |
map(f) when f constructs |
~1 per element | execArrayConstruct aliasing constraint |
error(expr) |
1 on throw | jsonError payload copy (exceptional path only) |
Exception — array construction aliasing: [.[] | f] where f constructs new data (object construction, arithmetic, string concatenation) allocates ~1 buffer per element. This is a structural limitation: execArrayConstruct must pass nil scratch to execMulti to prevent aliasing when an element's iterator emits multiple results across callback invocations. Elements that return input sub-slices (field access, identity, comparisons) remain 0 allocs.
Operations where allocation is proportional to what the caller asked to produce, not what was scanned. The caller controls the allocation by deciding what to request.
| Operation | Allocs | Reason |
|---|---|---|
range(n) |
1 per value | Each generated integer is a fresh byte slice. For range(10): 10 allocs. |
range(from;to;step) |
1 per value | Same model with explicit bounds and step. |
sort |
~n+O(log n) | Collect element sub-slices into index, sort, re-emit. No element copies. |
sort_by(f) |
~3n | Collect elements + compute keys + sort index. Keys with nil buf are sub-slices (no copy); constructed keys allocate their own buffers. |
unique, unique_by(f) |
~n | Same as sort, then deduplicate consecutive equal keys. |
group_by(f) |
~3n | Same as sort_by; groups consecutive elements with equal keys. |
transpose |
~n×m | Build transposed matrix: n rows × m columns, each element copied once. |
.[] == .[] multi-output compare |
0 | true/false are static literals; operands use nil-buf path. |
{a: .x[]} object construction |
~n per multi-output pair | Cartesian product: one prefix copy per value per level. |
Rule: Tier 2 operations must document their alloc model in CHANGELOG and SYNTAX.md. They must not appear in hot-path benchmarks that assert 0 allocs.
These operations are not rejected on principle — they're deferred because implementing them correctly requires a different execution architecture, not just documented allocations.
| Operation | Why deferred |
|---|---|
full have_decnum semantics |
Requires an exact decimal runtime instead of the current float-based executor. This is a semantic/runtime redesign, not an isolated builtin. |
module/import syntax, modulemeta |
Requires filesystem-backed resolution, dependency loading, and module scoping beyond the current pure Compile + Run library contract. |
input, inputs, env, $ENV, stderr |
These cross the host boundary by reading stdin, environment variables, or process stderr rather than operating only on the provided JSON bytes. |
- Supported operations: identity (
.), quoted field access (."foo"), field access (.foo,.foo.bar), array indexing (.[0],.[-1],.[i,j]), slicing (.[n:m],.[:m],.[n:],.[n:m]?), deletion (del(.foo),del(.[0]),del(.[i,j]),del(.[n:m]),del((paths) | ...),delpaths(paths)), iteration (.[]), recursive descent (..),recurse,recurse(f),recurse(f; cond),walk(f), object construction ({name},{a: .foo},{a: .items[]}), array construction ([.foo, .bar]), variable binding (expr as $x | body,. as [$a, $b] | ...,. as {$a, b: [$c]} | ...,?//alternation between binding patterns), user-defined functions (def f: body; expr,def f(args...): body; f(...)),reduce gen as $x (init; update),foreach gen as $x (init; update; extract?),while(cond; update),until(cond; next),label $name | body,break $name,map(expr),map_values(expr),with_entries(expr),add,expr + expr(including object merge),expr - expr, unary negation (-expr),expr * expr(includingstring * n,n * string,object * objectrecursive merge),expr / expr,expr % expr,flatten/flatten(n),split("s"),join("s"),repeat(expr),range(n)/range(from;to;step),min/max,min_by(f)/max_by(f),sort,sort_by(f),unique,unique_by(f),group_by(f),transpose,reverse,to_entries,from_entries,keys,keys_unsorted,paths,paths(filter),leaf_paths(compat alias),path(expr),pick(path, ...),getpath(path),setpath(path; value),todate,now,date(compat alias),tostream,truncate_stream(stream),fromstream(stream),bsearch(x),INDEX(gen; key),JOIN(index; key),IN(gen)/IN(lhs; rhs),any/any(expr)/any(gen; cond),all/all(expr)/all(gen; cond),first/first(expr),last/last(expr),limit(n; expr),skip(n; expr),nth(n; gen),isempty(expr), string interpolation ("\(expr)"), format templates (for example@html "<b>\(.)</b>"), pipe (expr | expr), grouping ((expr)), literals (null,true,false,"string",123,nan,infinite,-nan,-infinite), comparison (==,!=,<,<=,>,>=) with multi-output support, boolean (and,or,not),has("key"),length,abs,ascii_downcase,ascii_upcase,startswith("s"),endswith("s"),trim,ltrim,rtrim,trimstr("s"),ltrimstr("s"),rtrimstr("s"),utf8bytelength,if-then-elif-...-else-end,empty, select (select(cond)), alternative (//), optional (.foo?,expr?), assignment/update (=,|=,//=,+=,-=,*=,/=,%=), type (type),try/try-catch,error/error(expr),tojson/@json,fromjson,tostring/@text,tonumber,toboolean,have_decnum,builtins,$__loc__,@base64,@base64d,@uri,@urid,@html,@csv,@tsv,@sh,floor,ceil,round,nearbyint,contains(val),inside(val),index(s),rindex(s),indices(s),explode,implode,sqrt,fabs,atan(1-arg),pow(x; y),hypot(x; y),fma(x; y; z),log,log2,log10,exp,exp2,exp10,cbrt,logb,sin,cos,tan,asin,acos,tgamma,lgamma,j0,j1,isnan,isinfinite,isfinite,isnormal,test(re)/test(re; flags),match(re)/match(re; flags),capture(re)/capture(re; flags),scan(re)/scan(re; flags),sub(re; s),gsub(re; s) - Input format: valid JSON objects or arrays — no streaming, no JSONL
- No validation: assumes well-formed JSON input; behavior on malformed input is undefined
- No pretty-printing: output is compact JSON only, with canonical string escaping on emitted strings
paths,leaf_paths,path(expr),..,recurse, andwalkare parity-first exceptions: these implementations unlock official jq-suite coverage even though they are not held to the usual zero-alloc target.paths,leaf_paths, andpath(expr)allocate on the path-building call path,..benchmarks at7 allocs/op,recurseat5 allocs/op, andwalk(.)at12 allocs/opon the focused small benchmarks, all well below gojq for the same queries.if-then-elsecondition supports multi-output:execIfusesexecMultifor the condition, so generators likeif empty then x endcorrectly produce zero outputs. Single-output conditions take a fast path to avoid closure allocation.delpaths must be literal field, index, or slice expressions:del(.foo),del(.foo.bar),del(.[0]),del(.foo, .bar),del(.[n:m]), anddel(.[-n:])are supported. Dynamic paths are not:del(.items[])anddel(.items[] | select(...))both return an error. There is no way to delete multiple elements matched by a runtime condition.- Numeric slice bounds are expression-driven and local:
.[1.2:3.5],.[:rindex("x")], and chained forms like.[3:3][1:]are supported because bounds are evaluated once and clamped. This does not widen indexing into general dynamic path semantics.
- Scanner is stateless between runs:
struct { data []byte; pos int }reset per call - AST allocates once at compile time:
Compile()allocates,Run()does not (with buffer reuse, for Tier 0 ops) - Comma reconstruction: deletion never copies commas from input; reconstructs containers with own commas to avoid trailing-comma bugs
- String comparison without allocation:
bytesEqualStrcompares[]bytekeys tostringfield names without converting;findFieldStravoids[]byte(string)conversion at call sites - Multi-output via callback:
execMultiusesfunc([]byte) errorcallback to avoid allocating result slices internally - Negative indexing is two-pass:
arrayLen()counts first (no alloc), then iterates to resolved index - Precedence via function chain:
parsePipeExpr→parseExpr→parseAlt→parseOr→parseAnd→parseCmp→parseAtom— no precedence table - Generator contexts use jq-style array precedence: inside array construction and generator bodies, commas bind tighter than pipes so
[a, b | f]parses as[(a, b) | f] //operator collects all truthy left outputs:execAlternativeusesexecMultifor left side; if any output is truthy, it passes through; only if ALL outputs are falsy does right side run- Literals store raw JSON bytes: compiled at parse time, zero-alloc at runtime
- isFalsy by first-byte check:
n= null,f= false — one branch, zero alloc - Number comparison: byte-identical fast path (zero-alloc),
parseFloatslow path usingunsafe.Stringto avoid string allocation - Optional uses two representations: direct field/index/iterator forms keep
node.optional = truefor the hot path, while general postfixexpr?compiles to a wrapper op so jq-style transactional error suppression works across whole subexpressions trypropagateserrBreak:errBreakis a control signal (forfirst/limit), not an error —opTryinexecMultipropagates it unchangedtryis scoped to its body: downstream callback-chain errors are propagated past the innertryinstead of being caught as if they originated in the bodyexecroutes throughexecSingle:execSinglehandles all Tier 0 ops with direct return paths (no closures). Multi-output and Tier 1+ ops fall back toexecFirstResultwhich uses the closure-basedexecMultimachinery.elifdesugars at parse time:elif C then Xrewrites toelse (if C then X end)— no new op type needed- Regex patterns compiled at parse time:
node.re *regexp.Regexpholds the compiled RE2 pattern; Go RE2 guarantees linear-time matching (immune to ReDoS) findFieldStrstops on match: field lookups stop scanning as soon as the target key is found — no wasted scan of remaining fieldscompareJSONOrderfollows jq type ordering: null < false < true < numbers < strings < arrays < objects. Object comparison collects and sorts key-value pairs (allocates; acceptable for Tier 2 sort paths). This ordering drives all sort/min/max operations.hasMultiOutputcompile-time detection: object construction (opConstruct) checks all pair expressions at compile time. Single-output pairs useexecConstruct(zero-alloc fast path); multi-output pairs useexecConstructMulti(Cartesian product).- Sort uses index-based sorting:
sort_by/group_by/unique_byprecompute keys then sort a[]intindex to avoid mismatching precomputed keys with reordered elements.
- All operations must be covered by unit tests
- Benchmarks must compare against gojq on small (~100B), medium (~2KB), and large (~100KB+) JSON
- The root
fastjqmodule stays dependency-free; gojq comparison benchmarks live in the separatecompare/module - Benchmarks report ns/op, B/op, and allocs/op — any unexpected non-zero allocs/op on a Tier 0 operation is a bug
- Tier 1+ operations must document their alloc profile in the benchmark comment
- CLI throughput benchmarks compare against jq CLI on JSONL streams via
bench_vs_jq.sh