Rhai (evalexpr_rhai)
Run Rhai scripts inside DuckDB SQL.
On this page
Technical Overview
A sandboxed script for the rows SQL can't express
Run row-by-row scripted transforms when SQL alone can't express the logic, with bounded execution. Rhai is a small, Rust-native scripting language (source, book) — sandboxed by default, with no host filesystem, process, or network access from inside scripts. This extension exposes it as a single SQL function: evalexpr_rhai.
What it is
Rhai fills the gap between SQL that's too constrained for the expression you need and a full DuckDB UDF or Python pipeline that's too heavy to justify. Because the script is just text, it can live in a column — so dynamic rules (membership, scoring, eligibility), branching that would otherwise be a tower of nested CASE WHEN, and one-off custom math with named helpers can all be evaluated per row and edited without a redeploy. The sandbox is what makes the column-as-rule pattern safe: a script can only see the context object you hand it, never the host.
How it works
evalexpr_rhai parses the script string, evaluates it inside an embedded Rhai interpreter, and serializes the result back to DuckDB as a UNION(ok JSON, "error" VARCHAR). Constant script strings are parsed once and the resulting AST is cached, so the same script in a WHERE clause doesn't re-parse per row.
-
•
Single function, union return shape:
evalexpr_rhai(expression)andevalexpr_rhai(expression, context)— one name, two overloads. Read.okon success,.erroron failure. There is no separateevalexpr_rhai_safe/evalexpr_rhai_strictsplit; theUNION(ok JSON, error VARCHAR)return type is the entire error-handling story. -
•
Per-row context as JSON: The second argument is a
JSONobject DuckDB builds from row columns ({ 'salary': salary }). Inside the script it appears ascontext.<field>. Anything DuckDB can encode to JSON crosses the boundary — numbers, strings, booleans, arrays, nested objects. - • Compile-and-cache for constant scripts: When the script string is a literal (or otherwise constant within a query), the parser runs once and per-row cost after warmup is interpreter execution only. When the script comes from a column (the rules-from-a-column pattern), each distinct script string parses on first encounter and caches by content — a handful of distinct rules is cheap; thousands per query is where parse cost starts to bite.
-
•
Errors don't kill the query: A script that throws — runtime error, type error, divide-by-zero — produces the
errorarm of the union for that row, and the query keeps running. Read the failure with.errorif you need to surface it.
Performance and limits
Rhai is an interpreter running per row. Be honest about what that means.
- • Slower than vectorized SQL: Row-by-row interpretation is fundamentally slower than DuckDB's vectorized execution. Expect interpreter execution to dominate at high row counts — for hot OLAP paths over millions of rows, prefer native SQL or a compiled DuckDB extension function.
- • Best fit: moderate volumes: Where flexibility wins over peak throughput — config-driven rules, ad-hoc transforms, audits, exploratory analysis, batches in the thousands-to-low-millions of rows. Beyond that, profile.
-
•
Sandbox is intentional: Rhai has no module loading, no file I/O, and no networking — module loading is explicitly disabled in this extension. That's a feature here: you can evaluate untrusted script text from a column without granting it access to anything outside
context. If you need those capabilities, this is the wrong tool. See the Rhai Book — Safety chapter. - • Language is constrained: Rhai is smaller than full JavaScript: no package ecosystem, no async / await, no regex unless you reach for Rhai-specific helpers. The full feature set is documented in the Rhai Book.
Rhai vs. QuickJS — pick by need
evalexpr_rhai and the sibling quickjs extension occupy the same slot — embedded scripting in DuckDB SQL — but make different trade-offs. They can coexist in one session; pick per use case.
- • Pick Rhai when safety matters more than expressiveness: Sandboxed by default, no module loading, no host I/O, smaller language surface. The right choice for evaluating script text supplied by users or stored in a config table — there's nothing inside Rhai to reach the host with.
- • Pick QuickJS when you need full JavaScript: Full ECMAScript semantics, regex, JSON ergonomics, and the idioms developers already know. The right choice when you're porting expressions from a JavaScript codebase or need a richer standard library — at the cost of a larger attack surface and a heavier interpreter.
- • Both are row-by-row: Neither vectorizes. The choice is about language and sandboxing, not throughput. If raw throughput dominates, push the logic into SQL or a compiled UDF instead.
Deep Dive
Technical Details
What you can do with one query
The magic moment: store the rule as text, evaluate it per row, no redeploy.
CREATE TABLE eligibility(name TEXT, rule TEXT);INSERT INTO eligibility VALUES ('high_earner', 'context.salary > 100000'), ('senior', 'context.salary > 80000 && context.years >= 5'), ('manager_path', 'context.title.contains("Lead") || context.salary > 120000');
SELECT e.name AS bucket, emp.id, emp.nameFROM employees empCROSS JOIN eligibility eWHERE evalexpr_rhai(e.rule, { 'salary': emp.salary, 'years': emp.years, 'title': emp.title }).ok = TRUE;evalexpr_rhai parses each distinct rule string once, caches the AST, and runs the Rhai interpreter per row against the row’s context. Admins edit the eligibility table; the next query picks up the change. No code path between “rule edited” and “rule evaluated.”
Rhai runs row by row — it does not vectorize. Per-call cost after parse-cache warmup is interpreter execution only, but it’s still slower than native DuckDB SQL on the hot path. The right slot is config-driven rules, branching that’s awkward in CASE, custom math, and ad-hoc transforms — moderate volumes where flexibility wins over peak throughput.
For sustained OLAP scans over millions of rows, push the logic into SQL or a compiled DuckDB extension function. For full JavaScript expressiveness instead of Rhai’s smaller surface, see the sibling quickjs extension.
Result shape — UNION(ok JSON, error VARCHAR)
Every call returns a DuckDB UNION with two arms. On success, read .ok; on failure, read .error. The query keeps running either way.
SELECT evalexpr_rhai('5 + 6').ok; -- 11SELECT evalexpr_rhai('1 / 0').error; -- 'Runtime error: ...'This is the entire error-handling story — there’s no evalexpr_rhai_safe / evalexpr_rhai_strict split. A script that throws produces the error arm for that row; rows whose scripts succeed continue to produce .ok.
Per-row context as JSON
The second argument is a JSON object DuckDB builds from row columns. Inside the script it appears as context.<field>. Anything DuckDB can encode to JSON crosses the boundary — numbers, strings, booleans, arrays, nested objects:
SELECT id, evalexpr_rhai('context.qty * context.price * 1.0875', { 'qty': qty, 'price': price }).ok::DECIMAL AS taxed_totalFROM orders;For a multi-line script with a defined function, pass the whole thing as a string literal:
SELECT range AS n, evalexpr_rhai(' fn collatz(n) { let count = 0; while n > 1 { count += 1; n = if n % 2 == 0 { n / 2 } else { n * 3 + 1 }; } count } collatz(context.n) ', { 'n': range }).ok::INTEGER AS lengthFROM range(1000, 1005);The Rhai language itself — operators, control flow, type system, the standard string / array / map methods — is documented in the Rhai Book. The source and issue tracker live at rhaiscript/rhai.
Compile-and-cache for constant scripts
When the script string is a literal in the SQL — or otherwise constant within the query — the Rhai parser runs once. The resulting AST is cached and reused for every row. Per-row cost after warmup is interpreter execution only.
When the script string comes from a column (the rules-from-a-column pattern in the lead snippet), each distinct script value parses on first encounter and caches by content. A handful of distinct rules is fine; thousands of distinct rules per query is the point at which parse cost starts to matter.
The sandbox
Rhai has no host I/O. Scripts cannot:
- Open files, read environment variables, or touch the filesystem.
- Spawn processes or invoke shell commands.
- Make network connections.
- Load Rhai modules from disk (module loading is disabled in this extension).
The only thing a script can see is the context object you pass in. That makes it safe to evaluate script text supplied by users or stored in a config table — there’s no host surface to reach. The full sandbox model is described in the Rhai Book — Safety chapter.
Rhai vs. QuickJS — pick by need
The sibling quickjs extension occupies the same slot. They can coexist in one DuckDB session; pick per use case:
- Rhai is more constrained and safer. Sandboxed by default, no module loading, no host I/O, smaller language. The right choice when you’re evaluating script text from users or a config table, or when you want a small, predictable surface.
- QuickJS is more expressive. Full ECMAScript semantics, regex, the JavaScript standard library, the idioms developers already know. The right choice when you’re porting expressions from a JavaScript codebase or need a richer standard library.
- Neither vectorizes. Both run row by row. If raw throughput dominates, push the logic into native SQL or a compiled UDF instead — the choice between Rhai and QuickJS is about language and sandboxing, not speed.
Install
INSTALL evalexpr_rhai FROM community;
LOAD evalexpr_rhai;
Quick Start
Quick expression
SELECT evalexpr_rhai('5 + 6').ok;
Evaluate per-row with a context object
SELECT name,
evalexpr_rhai('context.salary > 100000', { 'salary': salary }).ok AS high_earner
FROM employees;
Full script with a defined function
SELECT evalexpr_rhai('fn sq(x) { x * x } sq(context.n)', { 'n': 7 }).ok;
Reference
Extension Contents
Quick reference to all available functions and settings organized by category.
| Name | Type | Description |
|---|---|---|
|
Execute
Run Rhai scripts inside SQL — single-function surface. Pass an optional |
||
| evalexpr_rhai() |
Evaluate a Rhai expression or full script and return the result as a DuckDB UNION of ok (the JSON-encoded result on success) or error (the error message on failure).
|
|
No extension contents match that search.
API Reference
Function Documentation
Practical Examples
Cookbook
Real-world recipes and patterns for common use cases.
One-shot expression
The simplest call — no context, no setup. Returns a UNION(ok JSON, error VARCHAR):
SELECT evalexpr_rhai('5 + 6').ok; -- 11SELECT evalexpr_rhai('[1, 2, 3].map(|x| x * 2)').ok;-- [2, 4, 6]See evalexpr_rhai for the full signature.
Per-row evaluation with context
The second argument is a JSON object exposed inside the script as context.<field>. Build it from row columns:
SELECT name, evalexpr_rhai('context.salary > 100000', { 'salary': salary }).ok AS high_earnerFROM employees;SELECT id, evalexpr_rhai('context.qty * context.price * 1.0875', { 'qty': qty, 'price': price }).ok::DECIMAL AS taxed_totalFROM orders;Anything DuckDB can serialize to JSON crosses the boundary — numbers, strings, booleans, arrays, nested objects.
Rules stored in a column
The pattern that makes this extension worth the install. Store the rule as text, evaluate per row, edit the table to change the rule:
CREATE TABLE group_membership(group_name TEXT, logic TEXT);INSERT INTO group_membership VALUES ('managers', 'context.name == "George" || context.name == "Rusty"'), ('shift_leads', 'context.name == "John"'), ('employees', 'context.name == "Alex"');
SELECT DISTINCT group_nameFROM group_membershipWHERE evalexpr_rhai(logic, { 'name': 'John' }).ok = TRUE;-- 'shift_leads'Each distinct logic string parses on first use and caches by content.
Multi-line scripts and fn definitions
For anything beyond a one-liner, pass a multi-line script — define helpers with fn, call them at the bottom:
CREATE MACRO collatz_length(n) ASevalexpr_rhai(' fn collatz(n) { let count = 0; while n > 1 { count += 1; n = if n % 2 == 0 { n / 2 } else { n * 3 + 1 }; } count } collatz(context.n)', { 'n': n });
SELECT range AS n, collatz_length(range).ok::INTEGER AS lengthFROM range(1000, 1005);The full language reference lives in the Rhai Book.
Branching that’s awkward in CASE
When CASE WHEN chains share intermediate values or thresholds, Rhai with named locals reads better:
SELECT id, evalexpr_rhai(' let base = context.qty * context.price; let disc = if context.tier == "gold" { 0.20 } else if context.tier == "silver" { 0.10 } else { 0.0 }; let taxed = base * (1.0 - disc) * 1.0875; taxed ', { 'qty': qty, 'price': price, 'tier': tier }).ok::DECIMAL AS final_totalFROM orders;Reading the error arm
A script that throws produces the error arm of the union. The query keeps running:
SELECT evalexpr_rhai('1 / 0').error; -- 'Runtime error: ...'-- Surface failures alongside successesSELECT id, evalexpr_rhai(rule, { 'x': x }).ok AS result, evalexpr_rhai(rule, { 'x': x }).error AS failureFROM jobs;For a single column that’s “result on success, NULL on failure,” use .ok directly — it’s NULL for the rows whose script threw.
Filtering on a script result
Booleans returned from .ok work in WHERE directly:
SELECT *FROM employeesWHERE evalexpr_rhai('context.salary > 100000 && context.years >= 5', { 'salary': salary, 'years': years }).ok = TRUE;Constant script strings parse once and cache, so the rule isn’t re-parsed per row.
Diagnostics
-- Smoke-test that the extension is loaded and the interpreter runsSELECT evalexpr_rhai('1 + 1').ok; -- 2-- Check arrays / closures land intactSELECT evalexpr_rhai('[1, 2, 3].filter(|x| x > 1)').ok;-- [2, 3]If a call returns the error arm where you expected ok, the message in .error comes from Rhai itself — the Rhai Book covers the language’s error and exception model in the throw chapter.
Platform Support
Compatibility
Extension availability may vary by platform and DuckDB version. Check below to ensure this extension supports your environment before installation.
Quick Facts
Platforms
- Linux x86_64 aarch64
- Linux (musl) Not available
- macOS Intel Apple Silicon
- Windows x86_64
- WASM eh mvp threads
Compiled binary sizes
| Platform | Architecture | Size |
|---|---|---|
| Linux | x86_64 | 5.85 MB |
| Linux | aarch64 | 5.37 MB |
| macOS | Intel | 3.16 MB |
| macOS | Apple Silicon | 2.92 MB |
| Windows | x86_64 | 9.04 MB |
| WASM | eh | 973.3 KB |
| WASM | mvp | 1.16 MB |
| WASM | threads | 971.6 KB |
Compressed download size from the Haybarn extension repository.
DuckDB & Haybarn
Release calendar- DuckDB v1.5.5 Haybarn 1.5.5-rc1 Supported