Fast per core, and it scales across them
Ibex parallelises group-by, scans, filters and transforms across cores, and stays competitive with every engine on many common columnar queries even before those threads are counted.
Ibex
Ibex gives DataFrame pipelines their own compact, statically typed language. Explore in a REPL, embed the same code in Python or R notebooks, and compile it to C++23 when the pipeline needs to ship.
Ibex parallelises group-by, scans, filters and transforms across cores, and stays competitive with every engine on many common columnar queries even before those threads are counted.
Named clauses describe each transformation, column references are checked statically, and type errors are caught before the pipeline runs.
Use the REPL for exploration, notebooks for analysis, plugins for I/O, and C++23 codegen for native binaries.
Performance
Ibex is built for the expensive part of table work: grouped aggregation, rolling time windows, joins, filters, null handling, and reshaping. The benchmark suite compares each query against the same operation in Polars, DuckDB, ClickHouse, DataFusion, pandas, data.table, and dplyr.
Polars: 63.8 ms. DataFusion: 35.2 ms. Ibex single-threaded: 34.4 ms.
Polars: 290 ms. DuckDB: 66.1 ms. DataFusion: 49.1 ms.
Polars: 146 ms. DuckDB: 1.24 s. DataFusion: 14.0 s.
Ibex parallelises some operators by default. The benchmark page shows every
engine at its default settings alongside a single-threaded run of each —
including ibex-st, which makes Ibex's own threading gain
visible and gives a same-core comparison against the other engines.
How a program is shaped
An Ibex program is a handful of let bindings. Each one names a
table and the steps applied to it. There are no loops or mutable
variables — you describe the transformation you want and Ibex runs it.
Write a table name, then square brackets containing a comma-separated list of clauses — one operation each. The clauses run in the order you read them, each taking the whole table and producing a new one.
Tables are values: a pipeline returns a new table and leaves its
input alone. You can name the result with let, or feed
it straight into another set of brackets.
// Keep the busy rows, then three columns
prices[
filter volume > 1000,
select { symbol, price, volume }
];
// Name a result and reuse it
let active = prices[filter volume > 1000];
active[select { symbol, price }];
R integration
The ibex package provides a lazy ibex_tbl.
Supported verbs build an immutable Ibex plan, show_query()
reveals the generated source, and collect() returns a tibble
through Arrow C Data.
Captured R scalars cross as typed bindings rather than source text. Translation recognizes registered function identities, so masked or unknown R calls are never guessed to be Ibex externs.
Choose fallback = "error" to require an all-native
query, "warn" for a visible one-time collection into
local dplyr, or "collect" for the same boundary without
a warning. Once local, the pipeline stays local.
library(dplyr)
library(ibex)
query <- ibex_tbl(trades, fallback = "error") |>
filter(price > 10) |>
mutate(notional = price * size) |>
group_by(symbol) |>
summarise(total = sum(notional), .groups = "drop") |>
arrange(desc(total))
show_query(query)
result <- collect(query)
Reading the syntax
Ibex uses square brackets, braces, and parentheses for three distinct things. Knowing which is which is most of what it takes to read any snippet.
[ ] — a pipeline
Square brackets attach to a table and hold a list of clauses to apply:
prices[filter …, select …]. Chaining
[…][…] just feeds one result into the next.
{ } — a list of fields
Braces hold the named members a clause works on — the output
columns of select, the sort keys of order,
the columns of a schema. Think struct fields, not a code block:
{ avg = mean(px), n = count() }.
( ) — calls and grouping
Parentheses are the familiar kind: calling a function and grouping
arithmetic. mean(price),
(close - open) / open. The expressions inside clauses are
ordinary too — comparisons, math, function calls.
Comma-separated lists may end with a trailing comma, so multi-line calls and field lists stay easy to edit: columns(symbol, price,).
A worked example
A common task — collapse tick data into daily bars per symbol — shows how the pieces fit together.
select chooses the output
columns. Each entry is name = expression; a bare name
passes a column through unchanged.
by symbol groups the rows, so
the aggregates in select — first,
max, min, last — run once
per symbol. Drop the by and they would collapse the whole
table to a single row instead.
order then sorts the result. The
whole thing is one expression, bound to bars.
let bars = ticks[
select {
open = first(price),
high = max(price),
low = min(price),
close = last(price),
vol = sum(size)
},
by symbol,
order symbol
];
The vocabulary
These drop inside [ ] and compose in any sensible order. The
function reference and
cheat sheet have the rest.
filter predicate | Keep rows where the predicate is true |
select { fields } | Choose or compute output columns; aggregates when paired with by |
update { fields } | Add or replace columns, keeping all existing ones |
where predicate update { fields } | Replace columns in selected rows |
by key | Group for select / update (like SQL GROUP BY / PARTITION BY) |
order { keys } | Sort, with per-key asc / desc |
rename { map } | Relabel columns without touching data |
distinct { keys } | Deduplicate on one or more columns |
head n / tail n | Keep the first / last n rows (per group with by) |
a join b on key | Inner / left / right / outer / semi / anti / cross / as-of joins; pair different names with on { left_id = right_id }. Semi and anti joins return the left columns only; other kinds append the right columns. A name held by both inputs is an error unless the join carries suffix { "_left", "_right" }, which renames both sides of each clash; an empty string leaves that side alone. Same-name keys fold into one column and never clash. A null key matches nothing by default, not even another null, matching the three-valued rule filter uses; nulls equal after the keys makes nulls match nulls and only nulls. expect n:1 declares how the rows line up (many left rows per right row, one right row per left row) and fails the run when the data disagrees. take first / last / any keeps one of a row's matches, the first two reading the right value's stated order |
window duration | Lookback window for rolling aggregates on a TimeFrame (e.g. window 5m); aggregate expressions compose, so sum(price * volume) / sum(volume) is a rolling VWAP |
resample duration | Bucket a TimeFrame into fixed time intervals, then aggregate per bucket (e.g. 1m OHLC); aggregate expressions compose, so sum(price * volume) / sum(volume) is a bucketed VWAP |
Outside the pipelines are a few top-level forms: let bindings,
import to load a plugin, fn /
extern fn for reusable functions and data sources, and
Table { … } to build a table from literals.
Conditional values
case
Use case in any expression position. Conditions are considered
in order; the selector form is shorthand for equality checks, and
else is required.
let direction = case side {
"BUY" => 1,
"SELL" => -1,
else => NULL
};
Install & run
A prebuilt release is the quickest start. Build from source if you want the latest commits or a binary for your own platform.
Option A — download a release
Grab the prebuilt ibex REPL and bundled plugins, unpack,
and run — no toolchain required.
github.com/bobjansen/Ibex/releases ↗
# Unpack the archive for your platform, then:
./ibex --plugin-path ./plugins
Option B — build from source
Requirements: CMake 3.26+ and a C++23 compiler such as Clang 17+, GCC 13+, AppleClang, or MSVC 2022. Ninja is recommended on Linux and macOS; CMake's Visual Studio generator works on Windows.
# Linux/macOS with Clang or GCC
cmake -B build-release -G Ninja \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-release
# Windows, from a Developer PowerShell
cmake -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release --config Release
Run your first pipeline
./ibex --plugin-path ./plugins # from source: ./build-release/tools/ibex --plugin-path ./build-release/tools
import "csv";
let prices = read_csv("prices.csv");
// Five most-traded symbols by total volume
prices[
select { traded = sum(volume) }, by symbol,
order { traded desc },
head 5
];
Handy REPL commands: :load <file.ibex>,
:schema <table>, :head <table> [n],
:doc <name>, :help.
Where to go next
Read a CSV, inspect rows, transform columns, aggregate groups, and write the result back out.
Interactive timings and memory use against Polars, DuckDB, ClickHouse, DataFusion, pandas, and R.
The same query in Ibex, pandas, Polars, and SQL, side by side.
A guided walk through every clause, with runnable snippets for deeper evaluation.
CSV, first-party filtered Parquet scans with late materialization, SQLite via ADBC, and Kafka streaming into live dashboards.
Every built-in function with signatures and behaviour notes, including Unicode-aware length and byte-oriented byte_length.
One-page syntax and function reference once you know what you are looking for.