Ibex computes rolling OHLCV
up to 12× faster than DuckDB.

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.

Get started with Ibex → Docs, language reference and install instructions
Ibex Polars DuckDB ClickHouse fastest

Every scale, both window types — 3 symbols, 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.

What each engine runs

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.

Ibex
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 ];
Polars
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"))
DuckDB
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 symbol
ClickHouse
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
FROM t
WINDOW w AS (PARTITION BY symbol
             ORDER BY toUnixTimestamp64Micro(timestamp)
             RANGE BETWEEN 10000000 PRECEDING AND CURRENT ROW)
ORDER BY symbol

The 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.

Where Ibex loses on sliding windows — and where that flips — 20M rows, 32 cores

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.

Ibex stays linear. DuckDB doesn't. — sliding window, 3 symbols, 8 cores

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.

Sliding window, cost vs. row count
3 symbols, 8 cores — every engine but DuckDB scales linearly, k ≈ 1
DuckDB is superlinear on sliding windows. At 5M rows it's already 25× slower than Ibex; by 50M rows (8 cores) it needs 8 minutes 39 seconds and only completed once before hitting the run budget. Ibex needs 4.83 s for the same query.

Aligned windows: the lead widens with symbol count — 20M rows, 32 cores

This benchmark goes up to 100 symbols. Ibex's time barely moves as symbol count climbs; the field doesn't stay as flat.

More cores help, even on a handful of symbols — 3 symbols, aligned window

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.

A second workload: finished bars (resample)

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.

Ibex
t[ resample 10s,
   select { open = first(price),
            high = max(price),
            low = min(price),
            close = last(price),
            volume_sum = sum(volume) },
   by symbol ];
DuckDB
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_start

Ibex 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.

By symbol count — 20M rows, 32 cores

By row count — 3 symbols, 32 cores

Caveat, to be filled in later: ClickHouse's resample numbers are not well-behaved across core counts on this workload. The same 5M-row/3-symbol cell runs 29.6 ms at 8 cores, jumps to 196.7–262.5 ms at 16 cores, then settles at 157.8 ms at 32 cores — non-monotonic in core count on identical data, and the 16-core min/median gap (66 ms, ~34%) is far wider than the <5% seen everywhere else in this suite. It looks like chdb thread-pool overhead dominating a job too small to amortise it rather than a real scaling curve, but that is a guess, not a finding. Every ClickHouse number in the resample tables above and below should be treated as provisional until this cell is rerun several times to separate signal from noise.

Why it's fast

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.

A running window, not a re-aggregation

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.

Time order is part of the type

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.)

Parallel across symbols — and inside them

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.

Static types, columnar kernels

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.

Try it on your own data

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.

Read the docs → bobjansen.github.io/Ibex
Full window results — all 36 configurations × 4 engines, 8/16/32 cores (min of 5 runs; * = single run, budget-capped)
Full resample results — all 18 configurations × 4 engines, 8/16/32 cores (min of 5 runs)
Method. AWS 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.

Missing cells: DuckDB sliding-window at 50M rows / 3 symbols exceeded the run budget at all three core counts (519 s at 8 cores, 317 s at 16, 227 s at 32) and is reported as a single completed run rather than a 5-run minimum. Suites: benchmarking/window_ohlc/run.py (window) and benchmarking/window_ohlc/resample_run.py (resample).