Skip to main content

ClickHouse Native vs RowBinary format (go/no-go, 2026-07)

Environment: M5 Max / 128 GB (dev laptop), Rust 1.96.1 release, ClickHouse 25.6 in Docker, single-threaded server (max_threads=1, max_insert_threads=1), 200k rows/insert, medians over 15 interleaved reps. Harness: benchmarks/src/bin/ch_native_format.rs (ROWS=200000 ITERS=41 REPS=15 SERVER=1); raw JSON in benchmarks/results/. Four schemas: events (mixed realistic), metrics (fixed-width; regression guard), dims (LowCardinality-heavy), and dims_hc (a client-only stress variant — dims with one LowCardinality column at ~50k distinct values — guarding dictionary hash-collision pathology).

Methodology note: the rig is symmetric — both formats are timed through their real RowEncoder impls (per-row buffered_bytes + finish_chunk), reported as median-of-total-ns → f64 ns/row, with a bare free-function RowBinary line kept as a wrapper-overhead reference. An earlier revision of this table timed RowBinary at that bare function while Native paid the full RowEncoder pipeline shape, and truncated ns/row to integers, so the old and new numbers are not directly comparable.

Client encode cost

Client encode: ns/row by schema
Lower is better
Native offloads the row→column pivot to the client, so it costs more client encode CPU — schema-dependent.
NativeRowBinary
Client encode: ns/row by schema — Lower is betterNative: 66.7 ns; RowBinary: 34.3 ns; Native: 33.7 ns; RowBinary: 10.9 ns; Native: 48.8 ns; RowBinary: 27.5 ns; Native: 52.5 ns; RowBinary: 27.5 nseventsmetricsdimsdims_hcNative: 66.7 ns66.7 nsRowBinary: 34.3 ns34.3 nsNative: 33.7 ns33.7 nsRowBinary: 10.9 ns10.9 nsNative: 48.8 ns48.8 nsRowBinary: 27.5 ns27.5 nsNative: 52.5 ns52.5 nsRowBinary: 27.5 ns27.5 ns
Data table
GroupSeriesValue95% CIn
eventsNative66.68646 ns
eventsRowBinary34.311665 ns
metricsNative33.668335 ns
metricsRowBinary10.888545 ns
dimsNative48.75708 ns
dimsRowBinary27.52646 ns
dims_hcNative52.45229 ns
dims_hcRowBinary27.508335 ns
Apple M5 Max · commit 63084c56a8 · 2026-07-10

Both formats are timed through their real RowEncoder impls (per-row buffered_bytes + finish_chunk); the chart carries the exact ns/row per schema. A third rowbinary_bare arm — a bare serialize_row free function — is a control kept out of the chart: it shows the encoder-wrapper overhead is small, the wrapped RowBinary path costing ~0.6–1.4 ns/row over the bare serialize_row control (e.g. events 34.3 vs 33.7 ns/row, dims_hc 27.5 vs 26.1).

Compressed wire size (events, 200k rows)

The byte counts are recorded in the JSONL (lz4_bytes / zstd_bytes), but codec is a metric key here rather than a chartable variant dimension, so they are kept as a table.

CodecRowBinaryNativeNative smaller
lz49.59 MB4.92 MB48.7%
zstd:34.47 MB1.32 MB70.6%

(dims: lz4 75.3%, zstd 56.9% smaller; dims_hc: lz4 58.1%, zstd 46.2%; metrics: lz4 35.1%, zstd 62.0% smaller. Wire encoding is byte-identical to the previous pass — raw events 126→108 B/row.)

Server CPU (events, OSCPUVirtualTimeMicroseconds, median over 15 reps)

EngineRowBinaryNativeNative lower
Null (parse + block-form only)94.8 ms7.7 ms91.8%
MergeTree (end-to-end)123.6 ms37.1 ms70.0%
MergeTree − Null (format-independent)28.8 ms29.4 ms~equal (validates isolation)

(Server CPU varies ±~10% run to run; the parse-isolated Native win is consistently ~90%.)

Interpretation: Native moves the row→column pivot off the server onto the client. It costs ~1.7–3.1× more client encode CPU (schema-dependent) but cuts server parse CPU ~92% (and ~70% end-to-end on MergeTree) and compressed wire ~35–75% (schema- and codec-dependent). The relative gap is largest on the fixed-width metrics schema (~3.1×), where RowBinary is essentially a memcpy; in absolute ns/row the Native cost is highest on events and the LowCardinality-heavy dims/dims_hc schemas (dictionary + columnar building the server would otherwise do row-by-row). The MergeTree − Null delta is format-independent (28.8 ms ≈ 29.4 ms), confirming the parse-isolation method.

Encoder efficiency pass (measured, not guessed): making the per-row buffered_bytes seal-check O(1) (a cached size refreshed every 16 rows) and #[inline]-ing the column dispatch were kept. A hand-rolled FxHash for the LowCardinality dictionary was tried and reverted — it collided badly on high-cardinality keys (city, 5k distinct) and ran ~3× slower than the default SipHash. The dictionary hasher was then switched to foldhash (SMHasher-clean avalanche, per-instance seeding): the wire bytes stay identical — dictionary order is first-seen, not hash-order — and dims Native encode improved ~45%. (dims_hc, one LowCardinality column at ~50k distinct values, was added to guard the new hasher against collision pathology.) The first-record field-name check was also moved off the per-row path (probe-based, run once), fixing a ~1.6–1.8 ns/field/row regression, alongside micro-optimizations: a single up-front reserve in finalize-block, Array/Map offsets stored as LE bytes at push time, and the LowCardinality key-width match hoisted out of the write loop. The residual — Native ≈ 1.7–3.1× RowBinary encode depending on schema — is the irreducible serde value-at-a-time dispatch + columnar transpose cost (scattered per-column buffers + a finalize concatenation copy), deliberately traded for the server/wire wins.

Verdict: NO-GO on the strict gate (which required no client-encode regression — the fixed-width metrics schema still regresses well past 5%), so RowBinary stays the default and Native ships opt-in (format: native). Native is the better choice when the ClickHouse cluster is CPU-bound or egress/wire is the constraint — the offload is exactly the point. RowBinary is better when client CPU is the constraint. Not yet run as a sustained pass: point e2e_kafka_clickhouse at a format: native sink and sample system.metric_log/part_log — the extended E2E rig now carries those knobs.

ClickHouse insert transport (design validation, 2026-07)

A separate, earlier study with no machine-readable backing — the recorded times are kept as a table.

Environment: dev laptop, ClickHouse 25.6 in Docker, 2M rows (~30 MB), clickhouse crate 0.15.1. Source: spike branch worktree-agent-a01a4cdccd8d0ab10 (crates/etl-clickhouse/examples/rowbinary_spike.rs).

PathTime
Typed Insert (validation on)116 ms
Typed Insert (validation off)95–102 ms
Pre-encoded RowBinary via InsertFormattedencode 8.8 ms + send 103–105 ms

Transport parity (~20M rows/s locally); typed-path validation costs ~15–20% and a DESCRIBE TABLE roundtrip. Dedup verified: same insert_deduplication_token + identical batch → deduplicated; requires insert_deduplicate=1, wait_end_of_query=1, and on plain MergeTree a non-zero non_replicated_deduplication_window (server default 0 silently disables dedup; replicated tables default to a window of 100).