Ibex is a statically typed query language for columnar time-series data that compiles to native code. This is its core workload: running open / high / low / close / volume over a time window, per symbol, updated at every tick — the query at the centre of every market-data pipeline. Benchmarked against Polars, DuckDB and ClickHouse on a 32-core cloud box, identical data, outputs checked row-by-row against documented exceptions, at 8, 16 and 32 cores.
Bars are wall-clock time; shorter is faster. Ibex wins aligned windows outright. On sliding windows Polars edges ahead at 3–8 symbols — the section below explains why, and where that flips.
running OHLCV within a 10-second window, per symbol, ordered by symbol · identical Parquet, output checked row-by-row against documented exceptions
Each engine gets the query written the way that engine wants it — no translation penalty, no deliberately awkward SQL. Input is pre-loaded into memory and only compute plus full materialisation is timed, so Parquet reading is excluded for all four.
Output is grouped by symbol, as a downstream per-symbol stage needs it — that is why every query carries an ordering clause, and all four engines pay for it. We tried four other ways to get Polars there (sort first, lazy pipeline, partition_by, sorting on both keys); the one shown below was the fastest, so it is the one we run. It is also the most generous: it orders on symbol alone, while Ibex delivers symbol and ascending time within each symbol.
One output row per input tick, not one per finished bar. Every engine reports the open / high / low / close / volume as of each tick, so all four return the same value in the same row and the outputs can be compared cell by cell. It also means the result frame is as tall as the input — at 50M ticks each engine materialises 50M rows × 8 columns, and that full materialisation is inside the clock.
t[ select {
price = price,
open = first(price),
high = max(price),
low = min(price),
close = last(price),
volume_sum = sum(volume),
window_start = window_start() },
by symbol,
window 10s aligned,
order symbol ];part = ["symbol", "window_start"]
(df.with_columns(
pl.col("timestamp").dt.truncate("10s")
.alias("window_start"))
.with_columns(
pl.col("price").first().over(part).alias("open"),
pl.col("price").cum_max().over(part).alias("high"),
pl.col("price").cum_min().over(part).alias("low"),
pl.col("price").alias("close"),
pl.col("volume").cum_sum().over(part)
.alias("volume_sum"))
.sort("symbol"))SELECT timestamp, symbol, price,
first_value(price) OVER w AS open, max(price) OVER w AS high,
min(price) OVER w AS low, price AS close,
sum(volume) OVER w AS volume_sum,
time_bucket(INTERVAL '10s', timestamp) AS window_start
FROM t
WINDOW w AS (PARTITION BY symbol, time_bucket(INTERVAL '10s', timestamp)
ORDER BY timestamp ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
ORDER BY symbolSELECT timestamp, symbol, price,
first_value(price) OVER w AS open, max(price) OVER w AS high,
min(price) OVER w AS low, price AS close,
sum(volume) OVER w AS volume_sum
FROM t
WINDOW w AS (PARTITION BY symbol
ORDER BY toUnixTimestamp64Micro(timestamp)
RANGE BETWEEN 10000000 PRECEDING AND CURRENT ROW)
ORDER BY symbolThe sliding flavour replaces the tumbling bucket with a window ending at each tick. Ibex drops aligned; Polars switches to df.rolling(index_column="timestamp", period="10s", group_by="symbol"), which does not preserve input order, so the result is sorted back to (symbol, timestamp) and stacked positionally onto the original frame — not a join; DuckDB switches to RANGE BETWEEN INTERVAL '10s' PRECEDING AND CURRENT ROW; ClickHouse's RANGE offset must fit a 32-bit int, so it orders by microseconds instead of nanoseconds — the closest faithful expression it accepts.
"Checked row-by-row against documented exceptions" does not mean identical. Two things cause real, expected divergence, and run.py --verify checks that every mismatch is one of them rather than assuming there are none. First, tied timestamps: any two ticks within the same engine-specific precision (nanoseconds for Ibex, microseconds for ClickHouse) are peers, and which peer an engine picks as "first" or "last" is an ordering detail, not a bug — this affects both window flavours. Second, and specific to sliding windows: Ibex's frame is position-bounded (this tick and everything strictly before it within the window), while SQL RANGE is peer-inclusive at both ends, so a row can legitimately admit a later tick that shares its timestamp under Polars, DuckDB or ClickHouse and not under Ibex. Both exceptions are counted and reported by the verifier, not silently ignored; on this dataset they touch a small fraction of rows (single digits per million) and never occur on aligned windows outside of tied timestamps. Whether Ibex's sliding-window boundary is the right default is an open language question, tracked separately from this benchmark.
At 3 and 8 symbols, Polars is faster; by 20 symbols the gap has reopened in Ibex's favour and grows from there. Ibex is known to parallelise across symbols, which plausibly explains why its lead grows with symbol count — but we have not profiled Polars internals, so any specific claim about why it wins at low cardinality would be a guess dressed up as a fact. The exact crossover point wasn't measured either, since only these four symbol counts were tested; "somewhere between 8 and 20" is the honest resolution, not a precise threshold.
DuckDB and ClickHouse are slower than Ibex at every symbol count shown here — this section is specifically about the Ibex/Polars crossover.
Cost against data volume, both axes logarithmic — so a straight line is a power law and its slope is the scaling exponent k. Ibex, Polars and ClickHouse run close to the dashed “linear” reference: double the rows, double the work. DuckDB climbs away from it, because its cost tracks rows inside each window frame, not just rows in the table.
This benchmark goes up to 100 symbols. Ibex's time barely moves as symbol count climbs; the field doesn't stay as flat.
Ibex speeds up 1.47–1.52× going from 8 to 32 cores here, even with only three symbol groups to split — because aligned windows are also split at bucket boundaries within a group. We didn't measure CPU utilisation directly, so this is evidence of real parallel work beyond the three symbol groups, not a claim that the box is saturated.
The window suite above answers "what is the bar as of this tick", emitting one row per input tick. A resample query instead answers "give me the finished bar", emitting one row per (symbol, bucket). This is a more even race: Ibex always beats Polars, but DuckDB and ClickHouse are competitive and sometimes faster.
t[ resample 10s,
select { open = first(price),
high = max(price),
low = min(price),
close = last(price),
volume_sum = sum(volume) },
by symbol ];SELECT symbol,
time_bucket(INTERVAL '10s', timestamp)
AS window_start,
arg_min(price, timestamp) AS open,
max(price) AS high,
min(price) AS low,
arg_max(price, timestamp) AS close,
sum(volume) AS volume_sum
FROM t
GROUP BY symbol, window_startIbex beats Polars on every one of the 18 resample configurations tested — 2.6× to 8.3×, 8 to 32 cores. Against DuckDB and ClickHouse there's no clean overall pattern: across the nine 20M-row, low-cardinality cells (3–20 symbols × 8/16/32 cores), DuckDB wins 3, ClickHouse 4, and Ibex 2. The one place a clear symbol-count crossover does show up is the 32-core panel below — DuckDB wins at 3–20 symbols, Ibex regains the lead at 100 — but that pattern is specific to 32 cores, not the whole matrix.
Nothing exotic — it is what you get when the window operator is designed for time series from the start rather than added to a general query engine.
Ibex advances one running accumulator per group as it sweeps the rows, by design independent of how many ticks fall inside a window — this benchmark fixes the window at 10 seconds throughout, so that property itself isn't what's measured here. What is measured is cost scaling linearly in row count: k = 1.02 in the curve above, versus k = 1.64 for DuckDB, whose cost grows superlinearly with table size on this workload.
A time-series frame carries its time index in the type system. In this benchmark the input is already ordered before timing starts, so once a TimeFrame has established its time ordering, the window operator reuses it rather than re-proving it on every query. (Ibex can also sort an unordered input when asked — that path just isn't inside the timed region here.)
Groups run concurrently, one per core. For aligned windows each group is split further at bucket boundaries, since a row can only depend on its own bucket — so three symbols can still put more than three cores to work, rather than leaving most of the box idle (measured: 1.47–1.52× from 8 to 32 cores at 3 symbols, aligned; we didn't measure CPU utilisation directly, so this is evidence of parallel work, not of saturation). On sliding windows, Polars is faster at the measured 3- and 8-symbol points and slower at 20 and 100; we haven't profiled Polars closely enough to say why with confidence, only that the crossover is real and repeatable in this data.
Every column's type is known before the query runs, so the inner loop is a typed kernel over contiguous memory rather than a dispatch over boxed values. Grouping and ordering take integer fast paths — symbols are ranked once through their dictionary, not hashed per row.
The benchmark suite, the queries above and the generator for the tick data are all in the repository — you can reproduce every number on this page, or point it at your own.
m7i.8xlarge (32 vCPU, 128 GiB), 2026-07-28. Every engine reads the same Parquet files, is pinned to the same cores with taskset, and is given the same thread budget (POLARS_MAX_THREADS, DuckDB PRAGMA threads, ClickHouse max_threads). Input is preloaded; only compute and full materialisation are timed. Each figure is the best of 5 runs after a discarded warmup, except cells marked * which hit the 120–300 s per-execution budget on their first run and are reported as a single-sample result rather than being cut off entirely — that single completed run is a real observation, not a bound, and an unmeasured 5-run minimum for that cell could as easily be lower as higher. All four engines are checked row by row before any timing runs, and every mismatch is required to be explained by tied-timestamp ordering or, on sliding windows, a documented RANGE-frame difference (see above) — a benchmark reporting four timings for four different, unexplained answers is worth nothing. Tick interval 1 ms (dense enough that a 10-second bar holds real work, not a near-1:1 grouping). Polars 1.42.1, DuckDB 1.5.4, ClickHouse via chdb 26.1.0.benchmarking/window_ohlc/run.py (window) and benchmarking/window_ohlc/resample_run.py (resample).