Analytics, columnar formats, and bridges
The analytics lane
CX has an analytics lane. The same brackets that carry a config file also carry a typed columnar table; the same canonical-bytes-plus-hash identity that anchors a single document also anchors a billion-row dataset; the same evaluator that templates a Markdown report also streams over a multi-gigabyte file in bounded memory. The lane has three load-bearing pieces, and this section is about how they compose.
The three pieces are: a **logical data shape** (the `:table` type tag, with typed columns and nullable cells); a **physical wire format** (CXCol, the column-oriented streaming binary form, tag `0x63` in [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.11); and **bridges to the broader ecosystem** (Apache Arrow C Data Interface for zero-copy interop, Apache Parquet for cross-tool storage).
- **Logical**: `[orders :table :columns (...) (...) ...]` — see table-shape. Authored in CX text, parses into the same AST as any other CX element, validates against `.cxs` schemas, hashes via SHA-256 over canonical bytes.
- **Physical**: CXCol — the column-oriented streaming binary form. Row-group chunked, optionally zstd-compressed per chunk, optionally schema-driven for tag-density. See cxcol.
- **Bridges**: Arrow C Data Interface (zero-copy in-process handoff) and Parquet (cross-ecosystem storage). See arrow-bridge and parquet-bridge.
- **Streaming**: the CX streaming evaluator (see [`spec/streaming.md`](../../spec/streaming.md)) emits `StartTable` / `RowGroup` / `EndTable` events over CXCol data, so directives like `[?for]` and `[?modify]` can operate on tables that don't fit in memory.
- **Identity**: CXCol files have a stable SHA-256 over their canonical (uncompressed) bytes. Compression level does not change the hash. Chunk boundaries do — see cxcol-identity for the rule.
The rest of this section walks each piece. The normative spec documents are [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.10-3.13 (wire format) and [`spec/misc/table-api.md`](../../spec/misc/table-api.md) (host binding API). This guide is the working developer summary.
**Naming.** `CXCol` is the column-oriented streaming format name. The `CXDB` / `CXDatabase` namespace is reserved for a future query-engine product and does not refer to the file format. Throughout this guide, CXCol is the format; `.cxcol` is the file extension; the `cxcol_*` prefix appears on host-binding identifiers.
The :table data shape
The `:table` type tag declares a typed columnar table at the logical CX layer. It is a CX element like any other — same brackets, same parse rules, same canonical-bytes contract — but the parser recognizes the `:table` body shape and produces a `Table` value rather than a generic element.
[orders :table
:columns (id :int, customer :string, amount :float, ts :datetime)
(1, 'Ada', 42.50, 2026-05-22T10:00:00Z)
(2, 'Grace', 17.25, 2026-05-22T10:15:00Z)
(3, 'Linus', 99.00, 2026-05-22T11:30:00Z)]
The `:columns` attribute carries the column spec: a parenthesized sequence of `name :type` pairs. The body is a sequence of row tuples, each tuple matching the column count. Tuple values must match the declared column types (or be `null` for nullable columns); cell-type mismatches are a parse error (`E_TABLE_CELL_TYPE`).
Supported column types match the CX scalar type system: `:int` / `:i8..:i64` / `:u8..:u64` (sized integers), `:float` / `:f32` / `:f64` / `:f16` (floats), `:bool`, `:string`, `:bytes`, `:date`, `:datetime`, `:decimal(precision, scale)`, `:bigint`, plus collection columns (`arr[T]`, `map[K, V]`, `seq[T]`). The full type catalog with byte encodings is in [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.10 + [`spec/misc/type-mapping.md`](../../spec/misc/type-mapping.md).
Nullable columns
Nullable columns are declared by wrapping the type in `nullable`. A null cell is written as the literal `null`.
[customers :table
:columns (id :int,
name :string,
phone (nullable :string))
(1, 'Ada', '555-1212')
(2, 'Grace', null)
(3, 'Linus', '555-9999')]
Nullable wire encoding is the def/rep-level pattern from [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.10.5; the host binding's `Table.cell(r, c)` returns the host's null sentinel (`None` in Python, `nil` in Go, `Option::None` in Rust).
Dictionary encoding
Tables are dictionary-encoded by default for `:string` columns with low cardinality. The encoder builds a per-chunk dictionary of distinct values; the column buffer stores dictionary indices instead of raw strings. Decoders rebuild the strings transparently; the host-binding API never exposes the indices.
Dictionary encoding is automatic, not a user-visible flag. The threshold (≥ 4× compression vs. raw on the chunk) lives in [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.10.4. CXPath `=` and `[?match]` value comparison operate on the decoded string, so user code is dictionary-agnostic.
Schema validation
Tables validate against `.cxs` schemas the same way ordinary elements do. A schema describes the column spec; the validator enforces row-count, type-conformance, and nullability per cell. See [[data#schema]] for the schema language and [`spec/schema.md`](../../spec/schema.md) §6 for table-specific rules.
[# orders.cxs — schema describing the orders table shape #]
[schema
[table :name orders
[column :name id :type int :required true]
[column :name customer :type string :required true]
[column :name amount :type float :required true]
[column :name ts :type datetime]]]
The Table host-binding API
Bindings expose a `Table` value type when `loads` encounters a `:table` block. Methods match across bindings up to host-idiom differences; the canonical surface is documented in [`spec/misc/table-api.md`](../../spec/misc/table-api.md).
import cxlib
doc = cxlib.loads(open('orders.cx').read())
t = doc['orders'] # Table instance
print(t.cols) # ['id', 'customer', 'amount', 'ts']
print(t.row_count) # 3
print(t.row(0)) # OrderedDict([('id', 1), ('customer', 'Ada'), ...])
print(t.column('amount')) # [42.50, 17.25, 99.00]
# Slicing / projection
sub = t.head(2).select(['customer', 'amount'])
print(sub.to_cx())
The 17-member canonical surface (`cols` / `types` / `row` / `column` / `cell` / `slice` / `head` / `tail` / `select` / iteration + adapters) is described in [`spec/misc/table-api.md`](../../spec/misc/table-api.md) §3. The same shape exists in V, Go, Rust, and TypeScript with the naming conventions of each host.
CXCol — column-oriented streaming format
CXCol is the physical wire form for tabular data: column-oriented, row-group-chunked, content-addressable, and streamable in bounded memory. The data-bin tag is `0x63`; the file extension is `.cxcol`. The normative byte-level layout is in [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.11.
The format is a single sequential pass: header, schema preamble, then a stream of row groups, then a terminator. Writers emit one row group at a time and can produce a billion-row table in memory bounded by the largest row group. Readers pull row groups one at a time and can skip groups by their length prefix without decoding them.
Wire layout
A CXCol stream is:
- **12-byte header.** Magic `CXDB`, format version, capability bits, header flags. Header flag bit 0 reserved; header flag bit 1 signals schema-driven mode (see cxcol-schema-driven).
- **Schema preamble.** The column spec: count, then one `(name, type_tag, nullable_flag)` triple per column. Encoded once at the head — every row group in the file conforms to this schema.
- **Row-group sequence.** Each group is `uvarint(body_byte_len) <body-tag> <body>`. `body-tag` `0x01` = plain (uncompressed column buffers); `0x90` = compressed with codec id (`0x01` = zstd v1).
- **Per-chunk manifest** (inside each group). Row count, optional per-column min/max/null-count statistics. The statistics are not yet used by libcx itself but are reserved space the Parquet bridge populates on emit.
- **Terminator.** A single `0x00` byte (zero-length row group) closes the stream.
The 2²⁰ (1,048,576) rows per group is the canonical chunk size — `cx canonical` enforces it on a CXCol input, and writers targeting cross-system hash invariance should use it. Different chunk sizes produce valid CXCol files but with different SHA-256 hashes.
Content-addressable identity
A CXCol file's SHA-256 hash is computed over its **canonical uncompressed bytes** — the bytes you would see if every `0x90` page-compression wrapper were unwrapped to its `0x01` plain form. Compression is a transport / storage choice; identity is a content choice; the format keeps them orthogonal.
- **Same data + same chunking + same schema → same hash**, regardless of zstd level or whether compression was applied at all.
- **Different chunk size → different hash.** Chunk boundaries are part of the canonical form. The 2²⁰-rows-per-group convention is the cross-writer interoperability rule.
- **Hash composes with hash.** The same SHA-256 primitive that hashes a CX document hashes a CXCol file — tooling pipelines (caching, dedup, signing, content-addressable storage) treat both uniformly.
import cxlib
# Two CXCol writers, same source data, different compression
cxlib.write_cxcol('a.cxcol', table, compress='zstd:3')
cxlib.write_cxcol('b.cxcol', table, compress='zstd:9')
# Files differ on disk:
open('a.cxcol', 'rb').read() == open('b.cxcol', 'rb').read() # False
# But canonical hashes are identical:
cxlib.cxcol_hash('a.cxcol') == cxlib.cxcol_hash('b.cxcol') # True
Streaming reader / writer
Bindings expose a handle-based streaming API mirroring the C ABI in [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.11.6 (10 symbols under capability bit 21). The Python / Go / Rust / V shapes follow each host's resource-management idiom.
import cxlib
# Reader — pulls one row group at a time
with cxlib.cxcol_reader('big.cxcol') as r:
print(r.schema) # column spec
for group in r: # iterator of row-groups
process(group) # group is a Table
# Writer — pushes one row group at a time
with cxlib.cxcol_writer('out.cxcol', schema=cols) as w:
for chunk in source_of_chunks():
w.emit(chunk) # bounded memory per chunk
package main
import "cxlib"
func main() {
r, _ := cxlib.CxcolReaderOpen("big.cxcol")
defer r.Close()
for {
g, err := r.Next()
if err == cxlib.EOF { break }
process(g)
}
}
use cxlib::cxcol::Reader;
let mut r = Reader::open("big.cxcol")?;
while let Some(group) = r.next()? {
process(group);
}
// r dropped → reader closed via Drop
Memory budget is one row group in flight per stage in the pipeline. A reader chained into a CXPath filter chained into a Parquet writer holds three row groups at once, total — regardless of the table's logical row count. See streaming for the directive-level view.
Schema-driven mode
When the writer knows both sides of the wire have access to the schema, header flag bit 1 enables schema-driven mode: per- value type tags are omitted for columns the schema declares; reader and writer walk the schema in lockstep with the data. Wire density approaches Parquet's typed encoding.
Schema-driven mode is opt-in (`cxlib.write_cxcol(..., schema_driven=True)`); having a schema in scope does not silently promote the encoder. The schema reference forms (`0x10` content-hash, `0x11` inline, `0x12` content-hash + name hint) are documented in [`spec/core/data-bin.md`](../../spec/core/data-bin.md) §3.13.
Schema-driven mode requires the reader to retrieve the schema by its content hash. Local lookup is the default; the callback-shaped content-store ABI is a planned follow-up.
Arrow bridge — zero-copy interop
CXCol bridges to Apache Arrow via the Arrow **C Data Interface** — a struct-based ABI that lets two libraries share columnar buffers without copying them. The CX side is `libcx_arrow`, a separate dynamic library that depends on libcx + the Arrow C-Data ABI. Core libcx is Arrow-free; capability bit 23 advertises Arrow when libcx_arrow is loaded.
The bridge exports four C ABI symbols: `cx_arrow_export_open` / `cx_arrow_export_open_fd` for CXCol → Arrow, and `cx_arrow_import_to_data_bin` / `cx_arrow_import_to_data_bin_fd` for Arrow → CXCol. Bindings wrap them as `to_arrow()` / `from_arrow()` on the Table value type.
Zero-copy handoff
The Arrow C Data Interface uses two structs: `ArrowSchema` (column types + names) and `ArrowArray` (per-batch buffers, including null bitmap, offsets, and values). CX populates these structs to point at its own column buffers; consumers (DuckDB, Polars, PyArrow, etc.) read from the same memory pages CX wrote.
Ownership crosses the boundary explicitly. The exporter populates an `ArrowArrayStream` with a `release` callback; the consumer calls `release` when done. CX's release callback frees the underlying CXCol buffers. There is no GC interplay — bindings hold the Arrow stream as an opaque handle until they're done with it.
import cxlib, pyarrow as pa
# Load CXCol — buffers held by libcx_arrow
t = cxlib.read_cxcol('orders.cxcol')
# Zero-copy export to Arrow: no buffer copy, just pointer handoff
reader = t.to_arrow_stream() # ArrowArrayStream
pa_table = pa.Table.from_batches(reader)
# pa_table now references the same memory as t.
# Don't free t (or call cxlib release) until pa_table is done.
use cxlib::cxcol;
use arrow::record_batch::RecordBatchReader;
let t = cxcol::read("orders.cxcol")?;
let mut reader: Box = t.into_arrow_stream()?;
while let Some(batch) = reader.next() {
let batch = batch?;
// batch.column(0) points at the same memory as t's first column
process(batch);
}
Type mapping
The CX ↔ Arrow type map is normative. The high-level summary:
- `:int` / `:i64` ↔ `Int64`; `:i32` ↔ `Int32`; sized variants (`:i8..:u64`) map to corresponding Arrow sized integer types.
- `:float` / `:f64` ↔ `Float64`; `:f32` ↔ `Float32`; `:f16` ↔ `Float16` (Arrow ≥ 1.0).
- `:bool` ↔ `Boolean` (bit-packed in Arrow).
- `:string` ↔ `Utf8` (or `LargeUtf8` for columns whose total byte length exceeds 2 GB).
- `:bytes` ↔ `Binary` (or `LargeBinary` similarly).
- `:date` ↔ `Date32` (days since 1970-01-01, proleptic Gregorian).
- `:datetime` ↔ `Timestamp(Nanoseconds, UTC)`; offsets round-trip via the `tz` field when present.
- `:decimal(p, s)` ↔ `Decimal128` (p ≤ 38) or `Decimal256` (p ≤ 76).
- `arr[T]` ↔ `List<T>`; `map[K, V]` ↔ `Map<K, V>`; `seq[T]` ↔ `List<T>` (sequence flatten per CXDM §1.2).
- `nullable T` → Arrow's standard nullable variant (validity bitmap).
Current column-type coverage in `libcx_arrow` is 9 of 10 types (int / i8 / i16 / i32 / float / bool / string / date / bytes). `:datetime`, `:decimal`, and dictionary / extension columns remain deferred to a future libcx_arrow expansion; the wire surface accepts them but the C-Data bridge errors with `arrow-014-unsupported-type-deferred-error`.
Arrow IPC stream format
Beyond zero-copy in-process handoff, CXCol can emit Arrow IPC streams for cross-process or cross-host transport. The IPC stream is Arrow's own serialization (schema header + record batches + EOF), distinct from the C Data Interface (in-memory structs).
import cxlib, pyarrow as pa
# Emit Arrow IPC stream — bytes you can write to a file or socket
with open('orders.arrow', 'wb') as f:
for batch in cxlib.read_cxcol('orders.cxcol').to_arrow_ipc():
f.write(batch)
The `cx table dump --to=arrow` CLI subcommand (`spec/cx_table_cli.md`) writes the IPC stream form by default; `--arrow-format=file` opts into the file-format variant.
Arrow Flight (planned)
Arrow Flight is Arrow's gRPC-based RPC framing for cross- process record batches. It is the natural next layer above the IPC stream — a Flight server publishes batches by descriptor; a Flight client subscribes. CX integration is **planned**: no Flight surface ships currently; the tooling-layer story will be specified when it lands.
For cross-process CXCol transport currently, use Arrow IPC over your existing transport (HTTP, pipe, socket). The `[?http-client]` directive can carry Arrow IPC streams as binary bodies.
Parquet bridge
CXCol bridges to Apache Parquet via Arrow: CXCol → Arrow stream → Parquet file. Core libcx has **no direct Parquet C++ dependency** — adding one would couple every binding to a heavyweight build-time toolchain. The Parquet adapter is a binding-specific library (PyArrow for Python, arrow-rs for Rust, parquet-go for Go, etc.) chained off the Arrow bridge.
Writing Parquet
`Table.write_parquet(path, ...)` walks CXCol → Arrow record batches → Parquet writer. The Parquet writer is the host binding's choice; the CXCol side just provides the Arrow stream.
import cxlib
t = cxlib.read_cxcol('orders.cxcol')
t.write_parquet(
'orders.parquet',
compression='snappy', # Parquet ecosystem default
row_group_size=1_048_576, # match CXCol canonical chunk
use_dictionary=True, # dictionary encoding (per-col)
write_statistics=True, # min/max/null per row-group
)
Defaults are the Parquet-ecosystem defaults (Snappy, not zstd) — output is meant to be consumed by Parquet readers that assume Snappy. Zstd is CX's default for CXCol's `0x90` page compression; the two formats use their own ecosystem defaults to minimize friction with the consumers of each.
use cxlib::cxcol;
use parquet::file::writer::*;
use parquet::basic::Compression;
let t = cxcol::read("orders.cxcol")?;
let mut writer = SerializedFileWriter::new(
std::fs::File::create("orders.parquet")?,
t.arrow_schema()?,
Compression::SNAPPY.into(),
)?;
for batch in t.into_arrow_stream()? {
writer.write(&batch?)?;
}
writer.close()?;
Reading Parquet
`read_parquet(path, ...)` is the symmetric inverse. The Parquet reader produces Arrow record batches; CX wraps them as a streaming Table.
import cxlib
# Whole-file read
t = cxlib.read_parquet('orders.parquet')
print(t.row_count)
# Streaming read with projection and predicate
for chunk in cxlib.read_parquet_chunks(
'orders.parquet',
columns=['id', 'amount'], # only read these columns
filter='amount > 100'): # predicate pushdown via stats
process(chunk)
Predicate pushdown uses per-row-group min/max statistics emitted by the Parquet writer. A predicate like `amount > 100` against a row group whose `max(amount) = 50` skips the group's data pages entirely. Column projection only reads the requested column chunks. Together, selective queries on wide tables can reduce I/O by 10× or more — see performance.
Predicate pushdown depends on the Parquet writer having emitted statistics. The CX writer (5.5.1) does so by default (`write_statistics=True`). Files produced by other writers may not have statistics — the reader still works, but degrades to a full scan.
CX ↔ Parquet type mapping
The mapping is normative. Most types round-trip cleanly through Arrow; a small number of CX types have lossy edges that `--strict` mode rejects rather than silently coerces.
- `:int` / `:i64` → `INT64`.
- `:i32` → `INT32`; `:i16` / `:i8` → `INT32` with `INT(N, true)` logical type; unsigned similarly.
- `:f64` / `:float` → `DOUBLE`; `:f32` → `FLOAT`; `:f16` → `FIXED_LEN_BYTE_ARRAY(2)` / `FLOAT16` (the latter is non-standard pre-Parquet 1.13).
- `:bool` → `BOOLEAN`.
- `:string` → `BYTE_ARRAY` with `STRING` logical type (UTF-8).
- `:bytes` → `BYTE_ARRAY`.
- `:date` → `INT32` with `DATE` logical type.
- `:datetime` (UTC) → `INT64` with `TIMESTAMP(NANOS, isAdjustedToUTC=true)`.
- `:decimal(p, s)` → `DECIMAL(p, s)` (Parquet caps at 256-bit precision).
- `:bigint` → `Decimal256(38, 0)` with diagnostic; `--strict` rejects.
- `arr[T]` → `LIST<T-projection>`.
- `map[K, V]` → `MAP<K, V>` (string-keyed).
- `seq[T]` → `LIST<T-projection>` after sequence flatten; `--strict` rejects (re-import produces `arr[T]`, not `seq[T]`).
- `nullable T` → `OPTIONAL` repetition.
Round-trip identity
CX → Parquet → CX is not in general byte-identical. Parquet is a presentation-layer format: footer offsets shift, row-group sizes are writer-dependent, encoding choices (dictionary thresholds, page sizes, compression codec) are per-writer. Many byte-level Parquet files encode the same logical data. CXCol fills this niche precisely — it is the hashable origin; Parquet is the bridge.
**Logical** round-trip is normative: a CX table emitted as Parquet and re-imported via `cx table load` produces the same `:table` block with the same column names, types, row count, and cell values (modulo the documented lossy edges in 5.5.3). The `cx_eq` primitive operates on the logical level and reports equality after round-trip.
import cxlib
t1 = cxlib.loads(open('orders.cx').read())['orders']
t1.write_parquet('orders.parquet')
t2 = cxlib.read_parquet('orders.parquet')
assert t1 == t2 # logical equality holds
assert t1.to_cxcol_bytes() == t2.to_cxcol_bytes()
# canonical CXCol bytes match too
Metadata preservation
Parquet's `KeyValueMetadata` is a list of UTF-8 key-value pairs at the file footer. CXCol metadata slots — column comments, table-level annotations, the original schema content-hash — map to entries with the `cx_*` prefix (`cx_schema_hash`, `cx_schema_inline`, `cx_column_doc.<name>`). Readers that don't recognize the prefix ignore it; CX readers re-attach the metadata on import.
import cxlib
t = cxlib.read_cxcol('orders.cxcol')
# Strict-schema mode embeds the CXCol schema as Parquet KV metadata
t.write_parquet('orders.parquet', strict_schema=True)
# Re-import preserves schema identity
t2 = cxlib.read_parquet('orders.parquet')
assert t.schema_hash() == t2.schema_hash()
Default writer mode does NOT embed the schema hash — Parquet consumers in other ecosystems wouldn't know what to do with it, and adding unrecognized metadata is a friction point. Use `--strict-schema` (CLI) / `strict_schema=True` (binding) when round-trip integrity matters.