TPC-DS: checked against the reference answers¶
A semantic layer is easy to believe when it returns a number. This page is about returning the right one.
40 queries from TPC-DS are written as OBSL query files against one OBML model, compiled to SQL, executed, and compared row-by-row with the benchmark's own reference SQL. Not totals, not row counts: every cell of every row.
The queries are in examples/tpcds_queries/, the model is
examples/tpcds.obml.yml, and nothing in either names an engine.
Results¶
| Engine | Scale | Exact | Known reference-variant differences | Unexplained |
|---|---|---|---|---|
| DuckDB | sf=1 | 39 / 40 | Q99 | none |
| ClickHouse | sf=10 | 37 / 40 | Q20, Q40, Q98 | none |
Every one of the 80 comparisons is accounted for.
A "known reference-variant difference" is a case where the reference query differs between engines, chased to ground and recorded rather than waved at:
- Q99 — DuckDB's variant wraps
cc_nameinLOWER()and ClickHouse's does not. Every numeric column matches; only the case of one string differs, and Q99 matches exactly on ClickHouse. - Q20, Q98 — ClickHouse's decimal division truncates to the operand scale,
so the reference yields
0.42where the true value is0.4254. All rows and all other columns match, and OBSL's value is the more accurate one. - Q40 — a
COALESCE(..., 0)in the model, added so an empty filtered measure reads as 0 on DuckDB, is wrong on ClickHouse, where the filtered measure already yields 0 rather than NULL.
How the comparison works¶
This is the part worth stating plainly, because "we ran TPC-DS" can mean almost anything.
Same database, same data, both queries. The OBSL-compiled SQL and the reference SQL execute against the same tables in the same engine in the same run. There is no cross-engine or cross-scale comparison anywhere: a match means two result sets are identical, not that two totals happen to agree.
Where the reference comes from differs per engine, and that is deliberate — each engine is checked against the reference its own ecosystem publishes:
| Engine | Data | Reference SQL |
|---|---|---|
| DuckDB | CALL dsdgen(sf=1) from DuckDB's tpcds extension |
The same extension's tpcds_queries() table function — all 99 official queries |
| ClickHouse | a local tpcds database at sf=10 |
The ClickHouse-adapted query set, read from a directory given by TPCDS_CLICKHOUSE_REF_DIR |
What is normalised, and what is not. Rows are sorted before comparison,
because neither side is required to return them in the same order unless the
query says so. Numbers are rounded to a per-query number of decimals — 2 for
money, 4 where a ratio is compared. Where the reference projects the same
count three times under different names, or orders its columns differently from
the way the planner groups them, the comparison maps the columns; each such
mapping is a line in sweep.py with a comment saying why. Nothing else is
adjusted: no tolerance on values, no dropping of rows.
Running it¶
# DuckDB. Needs examples/tpcds_queries/tpcds_sf1.duckdb, built once:
# python -c "import duckdb; c=duckdb.connect('tpcds_sf1.duckdb'); \
# c.execute('INSTALL tpcds; LOAD tpcds; CALL dsdgen(sf=1)')"
uv run python examples/tpcds_queries/sweep.py --dialect duckdb
# One query, or a few
uv run python examples/tpcds_queries/sweep.py --dialect duckdb Q53 Q63
# The compiled SQL for every query, no database needed
uv run python examples/tpcds_queries/sweep.py --dialect duckdb --dump
Every query runs on a connection of its own and writes its verdict to
results/<dialect>/<label>.json — match, row counts, the first differing row,
and how long it took. The whole DuckDB sweep is a few seconds of wall time; the
ClickHouse one at sf=10 is a long coffee, and the per-query files are what let
you see where it went.
--jobs sets the concurrency, and the default differs per engine because the
two are limited by different things. DuckDB takes 8: the sweep is a few seconds
of work against a local file. ClickHouse takes 1, and that is not caution.
At sf=10 its heavy tail is memory-bound rather than CPU-bound - three of those
queries in flight together asked for 25 GB on one server, spilled, and turned
Q69 from a few minutes into 52. Running them concurrently makes the sweep
slower, so it runs them one at a time.
The compiled SQL for both engines is committed under
examples/tpcds_queries/sql/, so a change's effect on all 40 queries is visible
in a diff without a database.
What it takes to express them¶
TPC-DS is not a star-schema benchmark. The queries that took real work are the ones whose shape a semantic layer usually cannot reach:
| Shape | Queries | How OBML expresses it |
|---|---|---|
| A window function over an aggregate | Q20, Q53, Q63, Q65, Q98 | A grain override on a measure, which compiles to AGG(x) OVER (PARTITION BY ...) in a wrapper over the grouped CTE |
| Filtering on that windowed value | Q53, Q63, Q65 | The predicate is hoisted past the window rather than applied inside the CTE |
| Hierarchical subtotals | Q22, Q27 | GROUP BY ROLLUP with GROUPING() flag columns |
| A seven-day pivot | Q43 | Seven filtered measures |
| Eight correlated subqueries in one scan | Q88 | Eight filtered measures over one query |
| Two facts at different grains | Q40, Q83 | The CFL planner's UNION ALL legs |
Nested boolean groups in WHERE |
Q15, Q34, Q73, Q79 | Query-level OR groups mixing dimension lists and raw column predicates |
What is not expressible yet¶
Four queries sit in examples/tpcds_queries/drafts/ and are not counted
above. They compile, but as approximations of the reference rather than
equivalents of it:
| Q | What it needs |
|---|---|
| Q31 | The same aggregate at three quarter offsets side by side in one row. Period-over-period metrics give the comparison row-wise, not pivoted into columns |
| Q49, Q70 | Rank-then-top-N. Q70 additionally wants rank() partitioned by GROUPING() output, which ROLLUP does not expose as a partition key |
| Q66 | Around 36 filtered measures over a web + catalog union, needing a date join on both facts. Expressible in principle; not built |
Counting them as passes would be the easy thing to do and would make this page worth less.
History¶
An earlier sweep in April 2026 verified 3 of the original 29 queries. The sweep
after it, in August, reached 29 of 30 on DuckDB and recorded five compiler gaps
that were blocking the rest — chief among them that a HAVING on a value
produced by a window wrapper was compiled inside the CTE, against the
pre-window aggregate, silently returning the wrong rows.
All five are now fixed, which is most of the distance between 29 and 39: Q19, Q53, Q63, Q65, Q68, Q72 and Q83 were blocked or partially blocked then and match exactly now.