olap trained
Every measurement here ran on DuckDB 1.5.5 and ClickHouse 26.10.1.831, local on one machine. The curated source list collects the references, and a runnable two-engine lab carries the full scripts.
mental model
- The storage layout is the index. Columnar engines store each column in compressed blocks: DuckDB row groups of 122,880 rows, Parquet row groups, ClickHouse granules of 8,192 rows. Blocks carry min/max statistics. A query is fast when it reads few columns and skips most blocks. Skipping only works when the data is physically sorted by the columns you filter on. In ClickHouse,
ORDER BYis that sort (primary indexes). In DuckDB and Parquet, zonemaps work best when rows were inserted sorted (indexing). - Execution is vectorized. Operators process column batches: 2,048-value vectors in DuckDB, blocks in ClickHouse. Aggregation over a few columns of billions of rows is cheap. Point lookups, row updates, and high-concurrency small transactions are not. Keep those in Postgres (
postgres). - ClickHouse is write-once and merge-later. Every INSERT creates an immutable part. Background merges combine parts, and engine semantics run at merge time: Replacing dedup, Summing and Aggregating fold, and TTL. Until a merge happens, duplicates and partial aggregates are visible (
parts,merges). - DuckDB is an in-process library over files. It needs no server. It queries CSV, Parquet, JSON, Iceberg, and Delta in place, locally or over https/s3 with range reads (
httpfs). It suits one machine with one writer. - Lakehouse = Parquet files + a table format. Iceberg, Delta, and DuckLake add snapshots, schema evolution, and file-level statistics (
lakehouse formats). The engine is replaceable, and the files are the contract.
examples
The runnable two-engine lab holds the full scripts.
-- ClickHouse: time series table. key = (filter column, time); codec by column shapeCREATE TABLE klines (symbol LowCardinality(String),ts DateTime64(3, 'UTC') CODEC(DoubleDelta, ZSTD),close Float64 CODEC(ZSTD), -- not Gorilla: see gotchasvolume Float64 CODEC(ZSTD),trades UInt32 CODEC(T64, ZSTD)) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (symbol, ts);-- incremental rollup: target table + MV created BEFORE insertsCREATE TABLE ohlcv_1d (symbol LowCardinality(String), day Date,open AggregateFunction(argMin, Float64, DateTime64(3,'UTC')),high SimpleAggregateFunction(max, Float64), low SimpleAggregateFunction(min, Float64),close AggregateFunction(argMax, Float64, DateTime64(3,'UTC')), volume SimpleAggregateFunction(sum, Float64)) ENGINE = AggregatingMergeTree ORDER BY (symbol, day);CREATE MATERIALIZED VIEW ohlcv_1d_mv TO ohlcv_1d ASSELECT symbol, toDate(ts) day, argMinState(open, ts) open, max(high) high, min(low) low,argMaxState(close, ts) close, sum(volume) volume FROM klines GROUP BY symbol, day;-- read: always re-aggregate (parts may be unmerged)SELECT symbol, day, argMinMerge(open), max(high), min(low), argMaxMerge(close), sum(volume)FROM ohlcv_1d GROUP BY symbol, day;
-- DuckDB: typed load from many CSVs, symbol from filename, sorted for zonemapsCREATE TABLE klines ASSELECT regexp_extract(filename, '([A-Z]+)-1m-', 1) AS symbol,CASE WHEN column00 >= 100000000000000 THEN make_timestamp(column00)ELSE make_timestamp(column00 * 1000) END AS ts, column04::DOUBLE AS closeFROM read_csv('data/*-1m-*.csv', header = false, filename = true) ORDER BY symbol, ts;COPY klines TO 'klines.parquet' (FORMAT parquet, COMPRESSION zstd);SELECT count(*) FROM 'https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet'WHERE passenger_count = 1; -- httpfs autoloads; only needed columns and row groups are fetched
uvx --from duckdb-cli duckdb -c "select version()" # DuckDB CLI with no installclickhouse local --path ./chdata --multiquery --output-format TSV < q.sql # persistent local MergeTree
best practices
- Choose the ClickHouse
ORDER BYfrom the queries you run. Put columns in low-to-high cardinality order, then time. Official docs:choosing a primary keyandcardinality order. A common community pattern says "high cardinality first", and measurement says otherwise:symbol(3 values) first reads 35/95 granules against 95/95 with time first. - Start without
PARTITION BY, or keep it coarse (month). Partitions are for data lifecycle (drop or move), not query speed (partitioning keys). The community advice to partition by month or day by default risks a too-many-parts failure. - Insert in big batches, roughly 10k-100k+ rows per insert. Use
async_insert=1for many small writers (insert strategy). - Pre-aggregate with an incremental MV into Aggregating or SummingMergeTree. Query it with
-MergeandGROUP BY(incremental MV). Use a projection when you only need a second sort order on the same table (MV vs projection). - Pick the narrowest types:
LowCardinality(String)for small vocabularies, Decimal for fixed-precision money, and noNullableunless NULL means something (select data type). - Avoid mutations (
ALTER UPDATE/DELETE) and routineOPTIMIZE FINAL. Model changes as inserts (ReplacingMergeTree with a version column) and read withFINAL, or useargMaxover the version (avoid mutations,avoid optimize final). - Enrich with dictionaries (
dictGet) instead of joining a small dimension (dictionaries). - DuckDB: write Parquet sorted by the filter columns, with zstd. For big file-to-file jobs,
SET preserve_insertion_order = falseand capmemory_limitto 50-60% of RAM if you hit OOM (oom,tuning). - Verify pruning with the plan, not wall-clock time. In ClickHouse, run
EXPLAIN indexes = 1and compare granules. In DuckDB, runEXPLAINand look for filters insidePARQUET_SCAN, or readparquet_metadata()stats (explain).
strengths
- DuckDB: zero-ops, embedded in Python, Node, or the CLI. It is the best tool for ad-hoc analysis over files, notebooks, ETL steps, CI data checks, and feature building (
feature-engineering,ml). It has friendly SQL (GROUP BY ALL,QUALIFY,arg_max, ASOF JOIN) and is the dbt-duckdb target indbt. - ClickHouse: a server for continuous ingest and many concurrent dashboards over billions of rows. It has incremental MVs, TTL, tiered storage, dictionaries, and very strong compression (DoubleDelta turned a 1.48 MiB
tscolumn into 2.5 KiB).clickhouse localgives the same engine with no server. - Parquet plus a table format keeps data engine-neutral. Both engines read each other's files; the round trip works in both directions.
weaknesses / pain points
- DuckDB: one writer process per database file, and it is not a multi-user server. Use MotherDuck or DuckLake to share it (
motherduck,ducklake). Large joins can exceed memory (oom). - ClickHouse: updates and deletes are expensive. Dedup is eventual. Joins are weaker than aggregation (
minimize joins). A badORDER BYneeds a table rewrite to fix. Running and replicating a cluster is real operations work. - Both: neither is an OLTP store. Keep the source of truth in Postgres and ship changes to OLAP.
gotchas
- Binance spot klines switched from ms to µs epochs on 2025-01-01. A naive
/1000gives year 57218 in DuckDB.fromUnixTimestamp64Millisilently clamps to 9999-12-31 in ClickHouse. Detect the unit (>= 1e14means µs). - An MV is an insert trigger. It saw 0 of 786,240 existing rows until an explicit
INSERT ... SELECTbackfill. - An AggregatingMergeTree target returns partial rows until merges finish. Always use
-MergeandGROUP BY. NeverSELECT *. - ReplacingMergeTree dedups at merge time. It returned 306,720 rows without
FINALand 262,080 with it. - Gorilla on decimal-string prices made storage worse: 1.46 MiB, against 1.19 MiB for LZ4, 845 KiB for ZSTD, and 597 KiB for Decimal64(2)+ZSTD. ALP reached 593 KiB, but it is beta in 26.10 and needs
enable_alp_codec=1. Measure codecs on your own data. - Small parts are
Compact, andsystem.columnsthen reports 0 bytes per column. UseSETTINGS min_bytes_for_wide_part = 0on a lab table to measure codecs. CREATE TABLE b AS a ENGINE = ...copies the codecs and the skip indexes. It is not a "default codecs" baseline.lagInFramereturns the type default (0) on the first row, not NULL. That producesinfreturns; DuckDBlagreturns NULL. Guard withisFiniteor pass a default.- In
clickhouse local,--formatsets the input format too. It brokeINSERT ... VALUESwithCANNOT_PARSE_INPUT. Use--output-format. - A skip index on a column uncorrelated with the sort key barely helps. minmax on
tradesstill read 74/95 granules. Put hot filters in the key or a projection instead. - ClickHouse
DateTime64(3,'UTC')becomes Parquet and then DuckDBTIMESTAMP WITH TIME ZONE. DuckDB's ownTIMESTAMPstays naive. Types change across the round trip. - TTL is evaluated against
now(). Historical backfills are deleted immediately: only 18,270 rows survived an 18-month TTL on 2024-25 data. - ClickHouse
extract(_file, ...)and DuckDBfilename = trueexpose the source path. Derive partition values like symbol from them, not from content.
known bugs
| engine / version | issue | workaround |
|---|---|---|
| ClickHouse master (2026-09) | #122346 FINAL returns a stale row when a skip index sits on a Float64 primary-key column | avoid skip indexes on float key columns with FINAL |
| ClickHouse | #122327 SELECT * EXCEPT/REPLACE with JOIN USING differs when wrapped in a subquery | list columns explicitly |
| ClickHouse | #122321 RENAME COLUMN after MODIFY QUERY raises LOGICAL_ERROR | recreate the MV instead of renaming |
| ClickHouse | #122284 Iceberg sorted insert violates the declared sort order across blocks | do not rely on the sort order of ClickHouse-written Iceberg files |
| ClickHouse | #120653 Delta writes: a timestamp_ntz column gets a UTC-adjusted value that Spark cannot read | write Delta from the lake's own engine |
| DuckDB 2.0 preview | #25670, #25704 remote Parquet reads regress (51x more HTTP requests) | stay on 1.5.x for remote Parquet |
| DuckDB | #25705 HTTP logging prints Authorization and s3 tokens in cleartext | do not enable HTTP logging with secrets |
| DuckDB | #25857 INT96 Parquet timestamps are truncated to µs | rewrite at the source as INT64 nanos |
troubleshooting
| symptom | cause | fix |
|---|---|---|
| year 57218 or 9999-12-31 after load | the epoch column mixes ms and µs, so the unit is ambiguous | branch on magnitude: make_timestamp for µs, fromUnixTimestamp64Micro where it applies |
| an MV table is empty or incomplete | the MV was created after the data | backfill with INSERT INTO target SELECT ... from the source, in time slices on big tables |
| duplicate rows in a Replacing table | the merge has not run yet | read with FINAL, or use argMax(col, version) GROUP BY key |
| a query reads all granules | the filter column is not a prefix of ORDER BY | reorder the key (new table plus INSERT SELECT) or add a projection; reordering took one case from 95 to 35 granules |
Too many parts | tiny inserts or a fine-grained partition key | batch the inserts, set async_insert=1, and choose a coarser partition or none |
CANNOT_PARSE_INPUT on INSERT VALUES in clickhouse local | --format also sets the input format | pass --output-format instead |
curl exit 92 downloading the ClickHouse binary | an HTTP/2 stream reset on a 174 MB file | run curl --http1.1 -fL -C - -o clickhouse <url> in a retry loop |
| DuckDB OOM on a big COPY | insertion-order preservation plus memory overcommit | set preserve_insertion_order=false and a memory_limit near 60% of RAM |
practiced cases
The two-engine lab runs 786,240 one-minute rows for 3 symbols (2024-10 to 2025-03) through DuckDB 1.5.5 (python and duckdb-cli via uvx) and ClickHouse local 26.10.1.831.
- The same OHLCV, VWAP, and window answers came out of both engines, and the MV rollup matched the raw rollup exactly.
- ORDER BY impact: a one-week, one-symbol query read 2/15 granules with
(symbol, ts)against 4/15 withts. A symbol-only query read 35/95 against 95/95. A projection fixed the second table (35/95). Sorting also shrank the table on disk: 20.00 MiB against 21.63 MiB. - Codecs: DoubleDelta on ts gave 819x. On close, Gorilla was the worst option (the codec gotcha above).
- Failure modes: the ms/µs epoch switch, the MV blind to existing rows, ReplacingMergeTree without FINAL,
lagInFrame→ inf,clickhouse local --format, and TTL deleting a backfill. - Parquet: DuckDB wrote it and ClickHouse read it (sum matched, 96.38M ETH). ClickHouse wrote it and DuckDB read it (ts became TIMESTAMPTZ). Sorted against shuffled Parquet meant 4/12 against 12/12 row groups to read for one symbol, and 28.6 against 31.8 MiB.
- Remote: DuckDB counted the 2.96M-row NYC taxi Parquet over https with a filter in about 8 s, with httpfs autoloaded. The host load average sat around 180-200 on 11 cores, so wall-clock times are not benchmarks.
ecosystem
These entries come from the official docs, not from hands-on runs.
| engine | pick when | watch |
|---|---|---|
| DuckDB | single-node analysis, files, embedded, notebooks | one writer |
| MotherDuck | shared or hosted DuckDB, hybrid local+cloud queries | a vendor service |
| DuckLake | a lakehouse with a SQL catalog and DuckDB-first work | young format |
| ClickHouse / Cloud | real-time ingest, high-concurrency dashboards, observability | key design is permanent; ops |
chDB | ClickHouse engine embedded in Python | same SQL dialect |
| BigQuery | serverless, pay per bytes scanned, GCP | partition and cluster, or pay for full scans |
| Snowflake | managed warehouse, separated compute, governance | credit burn from idle warehouses |
| Apache Pinot / Druid | user-facing, sub-second, high-QPS analytics from Kafka | ingestion specs, rollup-at-ingest |
| StarRocks | MPP with strong joins, queries Iceberg/Hive in place | cluster ops |
| Iceberg / Delta | multi-engine open tables on object storage | catalog choice, small files, compaction |
Modeling: use a star schema (facts plus conformed dimensions) for BI tools and dbt (dbt). Use one wide denormalized table for ClickHouse and Pinot serving, and resolve dimensions with dictionaries. Keep snowflake-normalized dimensions only when they are huge and shared (data modeling). Ingest by CDC or batch from Postgres (postgres) into Parquet or ClickHouse. Do not dual-write from the app.
Community lists: awesome-clickhouse, awesome-duckdb.
Read the olap skill.