Skip to main content

Deserialization formats

Which format is cheapest to decode? This rig answers it fairly: the same logical data is encoded as both Avro and JSON and decoded CPU-bound (no broker, no server, no network in the loop), reported per record so every format — and every JSON framing — is a directly comparable bar. As the framework gains formats this chart grows with them; today it is Avro and JSON.

Two workloads are measured: a nested batch of 50 richer readings (a bool, a float, and a small nested string array each) and a single flat 15-field record. Avro's typed arm uses the single-pass fast backend (serde_avro_fast) — Avro's recommended typed decoder and the fair peer to JSON's serde_json. See the Avro-fast pipeline study for Avro's backend A/B in depth.

Batch — per-record decode cost

Every bar below decodes the identical 50 readings; only the physical layout differs — Avro's nested datum, JSON as one nested document, as a top-level array, or as NDJSON (one reading per line). Throughput is normalized per reading.

Decode cost per record (typed)
Lower is better
Per-reading decode time for a 50-reading batch, by format and framing (serde_json). Lower is better.
Avro (fast · nested datum) (highlighted)other arms
Decode cost per record (typed) — Lower is betterAvro (fast · nested datum): 133 ns; JSON (nested document): 217 ns; JSON (top-level array): 217 ns; JSON (NDJSON): 239 nsAvro (fast · nested datum)JSON (nested document)JSON (top-level array)JSON (NDJSON)Avro (fast · nested datum): 133 ns133 nsJSON (nested document): 217 ns217 nsJSON (top-level array): 217 ns217 nsJSON (NDJSON): 239 ns239 ns
Data table
VariantValue95% CIn
Avro (fast · nested datum)133.09331491243816 ns131.84725110418094 – 134.339378720695389
JSON (nested document)216.59970619021453 ns215.56086335802487 – 217.63854902240429
JSON (top-level array)216.72896814347578 ns215.85182778789837 – 217.60610849905329
JSON (NDJSON)238.98702896610382 ns238.11268804114553 – 239.86136989106219
Apple M5 Max · commit 512ed5e0d2 · 2026-07-14
Avro (fast) vs JSON NDJSON — decode cost per record
1.8×
typed decode · lower ns/record is better
JSON NDJSON vs JSON array — reframing overhead
1.1×
both serde_json typed · same 50 readings

Avro's compact binary datum decodes fastest — roughly 1.8× cheaper per record than JSON typed, since JSON pays to parse text, unescape strings, and re-parse numbers. Among JSON's framings the difference is small: a nested document and a top-level array sit within a few percent of each other (the array marginally faster), while NDJSON trails by ~10–15% because it re-frames per line (a line split plus a fresh parse per record) instead of amortizing one parse across the whole batch. NDJSON buys that back in per-line error isolation and true streaming — the design trade-off the JSON format page covers.

Typed vs dynamic

Decoding into your own structs (typed) versus a dynamic tree (serde_json::Value / apache_avro::Value) is the other lever. The dynamic path allocates a node per field and is roughly 2× costlier for both formats — use it only when the schema is unknown at compile time.

Dynamic (value) decode cost per record
Lower is better
Same batch decoded into a dynamic value tree instead of typed structs. Lower is better.
Avro (fast · nested datum) (highlighted)other arms
Dynamic (value) decode cost per record — Lower is betterAvro (fast · nested datum): 244 ns; JSON (top-level array): 382 ns; JSON (nested document): 386 ns; JSON (NDJSON): 412 nsAvro (fast · nested datum)JSON (top-level array)JSON (nested document)JSON (NDJSON)Avro (fast · nested datum): 244 ns244 nsJSON (top-level array): 382 ns382 nsJSON (nested document): 386 ns386 nsJSON (NDJSON): 412 ns412 ns
Data table
VariantValue95% CIn
Avro (fast · nested datum)243.59414190481962 ns242.31952179635962 – 244.868762013279629
JSON (top-level array)381.7846096056321 ns378.9943848069693 – 384.57483440429499
JSON (nested document)385.87737547912917 ns384.07295732675937 – 387.6817936314999
JSON (NDJSON)411.80541803807387 ns410.4437497902683 – 413.16708628587949
Apple M5 Max · commit 512ed5e0d2 · 2026-07-14

Single record — a flat 15-field row

The batch shape stresses nested-array and small-object decoding; this one is a single flat record decoded per payload, so it isolates per-field scalar cost.

Flat 15-field record — decode cost
Lower is better
Per-record decode time for one flat 15-field order (serde_json). Lower is better.
Avro (fast) (highlighted)other arms
Flat 15-field record — decode cost — Lower is betterAvro (fast): 186 ns; JSON (serde_json): 346 nsAvro (fast)JSON (serde_json)Avro (fast): 186 ns186 nsJSON (serde_json): 346 ns346 ns
Data table
VariantValue95% CIn
Avro (fast)186.1334070460607 ns183.53495005485254 – 188.731864037268889
JSON (serde_json)346.2500764399581 ns344.73634639616785 – 347.763806483748349
Apple M5 Max · commit 512ed5e0d2 · 2026-07-14

SIMD backend — serde_json vs simd-json

Everything above decodes with serde_json, the default backend. The byte-slice → value step sits behind an internal seam, so the opt-in simd Cargo feature (json-simd on the etl facade) swaps in simd-json — a SIMD-accelerated parser — with no API change. Same 50 readings, same typed decode; only the parser differs.

serde_json vs simd-json — typed decode per record
Lower is better
The identical 50 readings decoded by each backend, per framing. Lower is better.
serde_json (default)simd-json (opt-in)
serde_json vs simd-json — typed decode per record — Lower is betterserde_json (default): 217 ns; simd-json (opt-in): 153 ns; serde_json (default): 217 ns; simd-json (opt-in): 154 ns; serde_json (default): 239 ns; simd-json (opt-in): 206 nssingle docarrayNDJSONserde_json (default): 217 ns217 nssimd-json (opt-in): 153 ns153 nsserde_json (default): 217 ns217 nssimd-json (opt-in): 154 ns154 nsserde_json (default): 239 ns239 nssimd-json (opt-in): 206 ns206 ns
Data table
GroupSeriesValue95% CIn
single docserde_json (default)216.59970619021453 ns215.56086335802487 – 217.63854902240429
single docsimd-json (opt-in)153.30910499738616 ns152.57057944436073 – 154.047630550411589
arrayserde_json (default)216.72896814347578 ns215.85182778789837 – 217.60610849905329
arraysimd-json (opt-in)154.04921266107297 ns153.2298160349762 – 154.868609287169739
NDJSONserde_json (default)238.98702896610382 ns238.11268804114553 – 239.86136989106219
NDJSONsimd-json (opt-in)205.95719103952965 ns205.19393179361532 – 206.720450285443979
Apple M5 Max · commit 512ed5e0d2 · 2026-07-14
simd-json vs serde_json — single document
1.4×
typed decode · higher × = simd-json faster
Mandatory in-place-parse copy
1.09ns
per reading · negligible vs the ~150–240 ns decode

simd-json parses a mutable buffer in place (it unescapes strings into the buffer). The framework hands the decoder a borrowed, immutable payload — for a Kafka message it is librdkafka-owned memory, and at-least-once replay needs the original bytes intact — so the payload is copied into a reused thread-local buffer first, and the parser's own scratch buffers are reused across calls too. Both are per-message allocations a production integration avoids; the memcpy_baseline bar isolates what remains — the copy itself, about 1 ns per reading, under 1% of the decode.

On the single-document and top-level-array framings — one parse over the whole payload, and the JSON connector's Kafka-message default — simd-json decodes roughly 1.4× faster than serde_json. On NDJSON (50 independent line parses) the gap narrows to ~1.15× as per-parse overhead dominates, and on the flat single-record order shape it is ~1.25×. Every arm is faster; the win is largest exactly where one large document is parsed.

Measured on aarch64

These numbers are from an Apple M5 Max (aarch64, NEON) — the machine every page here uses. simd-json's speedup is architecture-sensitive; an x86-64 host with AVX2 was not measured. serde_json remains the default backend, and simd is a per-deployment opt-in worth benchmarking on your own hardware and payloads.

Not byte-for-byte identical

simd-json is a different parser, so decode is not semantically identical to serde_json on every input. It rejects integer literals outside the i64/u64 range that serde_json accepts as f64 (such a document is dropped as malformed, or fails the payload under on_error: fail), normalizes -0 to 0, and does not honor the serde_json-specific arbitrary-precision, raw-value, or float-roundtrip features. The duplicate-key guard is unaffected — it always runs on serde_json. Ordinary-range numbers decode identically; validate against your own payloads before switching.

Reproduce

The rig is benchmarks/src/bin/deser_formats.rs; the shared shapes and both formats' encoders are in benchmarks/src/deser_sample.rs. One invocation measures one arm (mean of REPS reps, Student-t 95% CI). Sweep the matrix:

RESULTS=benchmarks/results/deser-formats.jsonl
for R in typed value; do
for F in avro json; do
SHAPE=order FORMAT=$F RECORD=$R REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
done
SHAPE=batch FORMAT=avro RECORD=$R REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
for FR in single array ndjson; do
SHAPE=batch FORMAT=json FRAMING=$FR RECORD=$R REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
done
done

# simd-json backend — rebuild with the feature; JSON typed arms.
SHAPE=order FORMAT=json RECORD=typed REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats --features json-simd
for FR in single array ndjson; do
SHAPE=batch FORMAT=json FRAMING=$FR RECORD=typed REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats --features json-simd
done

# memcpy-only baseline — the copy simd-json's mutable-buffer parse pays.
SHAPE=order FORMAT=json RECORD=typed COPY_ONLY=1 REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
for FR in single array ndjson; do
SHAPE=batch FORMAT=json FRAMING=$FR RECORD=typed COPY_ONLY=1 REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
done

Per-format micro-benchmarks (criterion) live in each format crate — crates/etl-json/benches/decode.rs and crates/etl-avro/benches/decode.rs. See Methodology for the harness and the Report schema the charts read.