RawDuck Benchmark

August 22, 2026 · View on GitHub

Primary workload: OTEL telemetry (OTLP/JSON logs, metrics, traces). GH Archive is a wide-schema stress test in the appendix.

All published numbers: DuckDB v1.5.5, default RawDuck settings, Apple M3 Ultra (32 cores, 512 GiB) unless noted. Cold ingest = first raw_ingest_file in a fresh database; warm = second ingest in the same process after DELETE (columns_added = 0). Report the best of N sessions unless noted. See scripts/benchmark/README.md for metric definitions.

Harness

GEN=ninja make release

# OTEL bulk ingest (NDJSON file)
./scripts/benchmark/run_otel.sh --quick
./scripts/benchmark/run_otel.sh --records 1000000 --runs 5

# OTEL streaming ingest (OpenTelemetry SDK → raw_serve HTTP)
./scripts/benchmark/run_otel_streaming.sh --quick
./scripts/benchmark/run_otel_streaming.sh --workers 16 --spans-per-worker 60000

# VARIANT vs RawDuck (same trace dataset)
./scripts/benchmark/run_variant.sh --quick
./scripts/benchmark/run_variant.sh --records 1000000 --runs 3

OTEL bulk ingest

1,000,000 records per signal, OTLP/JSON export envelopes (collector POST bodies), best of 5 sessions:

signalrecordssource NDJSONcold ingestrecords/sthroughput
traces1,000,000435 MB1.19 s841k366 MB/s
logs1,000,000294 MB0.87 s1.15M338 MB/s
metrics1,000,000353 MB1.13 s889k314 MB/s

3M telemetry records in 3.2 s (~940k records/s average). Warm ingest matches cold within ~2% on each signal.

Query speed (1,000,000 spans)

Same spans — shredded typed columns vs one JSON object per span (->>), best of 3 runs:

queryJSON ->>RawDuckspeedup
error count by service (status>=500)39 ms1.5 ms26×
p99 latency by route99 ms3.2 ms31×
status-code distribution35 ms2.5 ms14×
storage143 MB39.5 MB3.6× smaller

Reproduce

./scripts/benchmark/run_otel.sh --records 1000000 --runs 5
./scripts/benchmark/run_variant.sh --records 1000000 --runs 3   # queries + storage above

OTEL streaming ingest

Real OTLP/HTTP protobuf traffic via the OpenTelemetry Python SDK into raw_serve() (concurrent exporter processes, not bulk NDJSON):

workersspanswallspans/s
420,0000.75 s27k
16960,0003.85 s250k

rows_ingested must equal total_spans_sent (checked by the harness).

Reproduce

./scripts/benchmark/run_otel_streaming.sh --workers 16 --spans-per-worker 60000

First run creates benchmark/work/otel-streaming-venv (OpenTelemetry SDK dependency).

VARIANT vs RawDuck (DuckDB v1.5.5)

Same 1,000,000 OTLP/JSON trace spans. Paths:

pathdefinition
RawDuckraw_ingest_file(..., transform := 'otlp-traces') → typed columns
VARIANT OTLPSQL unnest → one VARIANT {resource, span} per span (KeyValue arrays kept)
JSON OTLPsame exploded shape as JSON
VARIANT-flatto_json(traces)::VARIANT of shredded RawDuck rows (encode/query only)
JSON-flatsame shredded rows as JSON, queried with ->>

Disk = used_blocks × block_size after cold CHECKPOINT. Parenthetical file size is after warm re-ingest (includes free-list holes; not comparable across paths). VARIANT requires STORAGE_VERSION 'v1.5.0'.

Ingest + storage (1,000,000 spans)

pathM3 UltraSpark GB10 aarch64 (--threads 8)
RawDuck0.99 s · 1.01M rec/s · 39.5 MB (108 MB file)1.18 s · 850k rec/s · 35.5 MB (91 MB file)
VARIANT-flatencode · 35.8 MBencode · 39.5 MB
VARIANT OTLP11.96 s · 84k · 53.5 MB (106 MB file)7.96 s · 126k · 54.5 MB (114 MB file)
JSON-flatencode · 143 MBencode · 142 MB
JSON OTLP4.72 s · 212k · 241 MB (484 MB file)5.71 s · 175k · 242 MB (484 MB file)

Spark GB10: 20 cores, 122 GiB, Linux aarch64.

Queries (best of 3, ms)

encodingM3 UltraSpark GB10
errors / p99 / statuserrors / p99 / status
RawDuck1.5 / 3.2 / 2.51.3 / 4.9 / 5.1
JSON-flat39 / 99 / 3565 / 136 / 63
JSON OTLP positional213 / 303 / 193253 / 415 / 215
JSON OTLP key lookup344 / 418 / 304397 / 580 / 366
VARIANT-flat436 / 1225 / 416700 / 1988 / 696
VARIANT OTLP positional1227 / 3479 / 11622013 / 5947 / 1852
VARIANT OTLP key lookup1493 / 3658 / 14042107 / 5996 / 2049

Reproduce

./scripts/benchmark/run_variant.sh --records 1000000 --runs 3
./scripts/benchmark/run_variant.sh --records 1000000 --runs 3 --threads 8   # many-core ARM

Appendix: GH Archive (wide-schema stress test)

One hour of GH Archive data — 247,199 events / 956 MB NDJSON / 914 columns. Apple Silicon, 10 cores, DuckDB v1.5.5:

JSON columnRawDuck
count by event type231 ms1 ms231×
top repos by pushes268 ms3 ms89×
distinct repos per actor457 ms10 ms46×
sum of push payload sizes265 ms1 ms265×
events per minute236 ms3 ms79×
cold ingest1.4 s~13 sone-time cost
warm re-ingest~4.9 ssteady state
storage1.05 GB636 MB40% smaller

Reproduce

curl -sL https://data.gharchive.org/2024-01-15-10.json.gz -o gh.json.gz
CALL raw_ingest_file('gh_events', 'gh.json.gz');
CHECKPOINT;

Pitfalls

  • Split NDJSON on \n only (not splitlines()\u2028/\u2029 appear in strings).
  • Use .timer on via duckdb -f script.sql (not duckdb -c).
  • Shallow duckdb clones without tags report v0.0.1; fetch tag v1.5.5.