How a value is represented at runtime
Transparency is the contract
CX pins what you can observe and leaves how the engine gets there free. Results, canonical bytes, content addresses, error codes, evaluation order, and equality must be identical whichever internal path executes — that is normative. Whether the engine walked a node tree, a byte slice, or a column buffer to produce them is quality of implementation.
This is the ruling that lets the engine get faster without the spec moving: no internal representation is identity-bearing (identity is canonical text, full stop — value-model), so a faster path that produces byte-identical observables is always admissible, and a faster path that changes one observable byte is always a bug. The conformance corpus pins the observables; pair-fixtures pin that the same data yields the same results and addresses as text, as binary, as chunked columns, and back from an Arrow round-trip.
The companion obligation is honest reporting: wherever a representation choice is invisible in results, it must be visible in introspection. Engines report which path executed — batch or node, pushdown or scan — through their status surfaces, so you can reason about performance without reading engine internals, and a full scan can never masquerade as an index.
Pure-functional updates — the spine copy
[?modify] names a location and a change and returns a new
document; the input is observably untouched (see the modify
material in code). What makes that affordable on large
trees is structural sharing: only the spine from the root
to each matched node is fresh; every unchanged subtree is the
same memory as the original.
input doc: output doc (after set on root/foo/@bar):
root [a] root' [a] ← new
├─ foo [b] ├─ foo' [b'] ← new
│ ├─ bar=1 ─┐ │ ├─ bar=2 ← new (set)
│ ├─ baz ─┼─── shared ──┤ ├─ baz ← same ref
│ └─ children ┘ │ └─ children ← same ref
└─ qux └─ qux ← same ref
\"Same ref\" is literal — the same pointer. The two documents
differ in identity (different addresses, eq is false) while
sharing almost all of their bytes in RAM. Allocation is
O(depth × matches), not O(document size); the GC reclaims
shared subtrees when the last document referencing them goes
unreachable. It is the same persistent-data-structure idiom as
Clojure's maps and Haskell's lenses — and it is why a pipeline
of ten modifications over a large document costs a fraction of
ten copies.
Two consequences worth owning. Thread safety for free:
since no node is ever mutated, any number of threads can read
shared subtrees without locks — which is half of why [par]
is cheap (see computation-identity). The identity
invariant: no API in any binding mutates a node in place;
there is no set-attribute-in-place anywhere in the surface,
and there never will be — immutability here is structural, not
disciplinary.
Streaming — bounded memory over unbounded input
Large inputs — log streams, exports, telemetry — do not have to
materialize as trees. The [lazy] clause on [?for] asks the
evaluator for a stream-aware pass: parse events flow through
the iteration body row by row, and resident memory is bounded
by what the body accumulates, not by the input.
[logs [entry level='error' service='api']
[entry level='info' service='api']
[entry level='error' service='db']]
[?for [in $row //logs/entry] [lazy]
[where [= $row@level 'error']]
[yield $row@service]]
Streaming is for forward passes. Random access is what
trees are for — you cannot jump to the 99th match of a stream
without consuming 98 — and [lazy] is a hint the evaluator may
partially decline when a predicate genuinely needs
materialization. The write-side dual exists too: the
event-writer surface emits CX to a wire without ever holding
the document, enforcing grammar invariants at emit time.
Streaming composes with chunked columnar tables (next
subsection): a row-group reader with per-column statistics
feeding a [lazy] pipeline is the constant-memory shape for
columnar workloads.
Columnar data in flight
Bulk tabular data moves as columns, not boxed rows — on the wire (the chunked table format) and, progressively, in flight inside the engine. The logical value model does not change: columnar is a representation beneath the transparency line, with a small set of observable rules above it.
- Table rows are not children. A
[table[…]]block's rows are cells behind the table API, not CXDM child elements —$t//rowselects nothing. That carve-out is the columnar seam: it is what lets a million rows live as column buffers without a million element boxes. - The column type lattice is the wire's. A decimal column never silently becomes a string column or an int column; declared widths keep their width; irregular columns fall back to a per-row-tagged escape, honestly reported — never silently widened.
- Atom columns dictionary-encode by construction — tag-shaped values are the natural dictionary fit.
- Secrets never get a raw columnar cell. Redaction semantics cannot be bypassed by a bulk buffer; secret-bearing shapes force the node path. A security rule, not a performance one.
Where node form is required to exist (materialized or indistinguishably emulated), because it is observable: identity and hashing (always over canonical text), inspection surfaces (emitters, AST projections, the LSP), CXPath navigation, pattern matching, quasiquotation, and metadata reflection. Everywhere else, the engine is free — and expected — to stay columnar for bulk movement and unboxed for scalar work.
Performance characteristics
The honest summary: CX is fast where the representation story
above is finished and measurably behind its own targets where
it is not — and the project publishes both. Numbers live where
they are re-measured, not in prose that goes stale: make
bench refreshes the bench record, the perf gates in the
release process enforce floors, and this page teaches the
shape of the costs.
- Parsing is allocation-bound. The classic tree parse allocates per element; huge inputs want the streaming or binary paths. Binary AST and schema-driven binary decode skip text lexing entirely; chunked columnar tables also skip row boxing.
- A parsed document costs more resident memory than its source bytes (headers, interned names, attribute maps) — measure before planning capacity, and lean on structural sharing: derived documents cost their spine, not their size.
- The canonical emitter pays a normalization pass over the regular emitter — you buy identity with it.
- FFI overhead is per-call, not per-byte — negligible per document, dominant per tiny call in a hot loop; batch at the boundary.
When CX wins over JSON: large documents (the binary forms are smaller on the wire and cheaper to parse); repeated parsing under content-addressed caching (canonical bytes hit caches that formatting-sensitive formats miss); type-tagged data (dates, decimals, sized integers as first-class scalars); schema-validated boundaries; and shipping code beside data over one wire with one identity story.
When JSON wins over CX: consumers that only speak JSON (project to it and pay the type loss knowingly); tiny one-off payloads where ubiquity beats capability; flat key-value config where TOML or JSON is already enough. The comparison page (comparison) carries the full argument in both directions.