1. Your first scalar function
The first tutorial step: build a worker with one scalar function and call it from SQL — about 10 minutes, for first-time VGI users with Rust 1.97+ (the crate’s MSRV).
What's a “worker”?A worker is a small program DuckDB launches as a subprocess and talks to over Apache Arrow. It exposes one or more typed functions, and DuckDB calls them like built-ins. It is an ordinary Rust binary — nothing is compiled into DuckDB, and nothing links against it.
double is intentionally trivial — DuckDB can already do n * 2. The point is that process is
ordinary Rust: pull in a crate, a model, a reqwest call, a parser DuckDB has never heard of,
and DuckDB calls it like a native SQL function.
Step 1 — Write the worker
Section titled “Step 1 — Write the worker”Create a crate and add the SDK:
cargo new calcscalar && cd calcscalar
cargo add vgi arrow-array arrow-schema
arrow-array and arrow-schema are yours to name because your function signature is written in
Arrow types. Keep them on the same major as the SDK — arrow-rs 59 for vgi 0.29.
Every trait method returns vgi_rpc::Result, and on 0.29.0 that name has to come from a direct
dependency — but the published crate wants vgi-rpc 0.21, while a bare cargo add vgi-rpc
installs 0.22. Cargo will not unify them (0.x minors are semver-incompatible), and the mismatch is
not a warning:
error[E0053]: method `process` has an incompatible type for trait
= note: expected ... -> Result<_, vgi_rpc::errors::RpcError>
found ... -> Result<_, RpcError>
So on 0.29.0, add vgi-rpc = "0.21" and import Result/RpcError from there.
From the next release this goes away: vgi re-exports the RPC layer, so use vgi::{Result, RpcError}; is the right version by construction and vgi-rpc leaves your manifest entirely. The
code below is written that way.
Then src/main.rs:
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
//! The worker built in step 1 of the vgi-rust tutorial: one scalar function,
//! served over stdio, callable from DuckDB as `calc.double()`.
//!
//! A scalar function is the simplest shape — one row in, one value out, with no
//! state and no finalize phase. DuckDB hands the worker a whole Arrow column and
//! expects a column of the same length back.
//!
//! ```text
//! cargo build --release --bin calcscalar
//! # then, in a Haybarn shell:
//! ATTACH 'calc' (TYPE vgi, LOCATION './target/release/calcscalar');
//! SELECT calc.double(21);
//! ```
use std::sync::Arc;
use arrow_array::cast::AsArray;
use arrow_array::types::Int64Type;
use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch};
use arrow_schema::DataType;
use vgi::catalog::CatalogModel;
use vgi::function::{ArgSpec, FunctionMetadata, ProcessParams, ScalarFunction};
use vgi::{Result, RpcError};
/// Doubles each value in its input column.
struct Double;
impl ScalarFunction for Double {
/// The SQL name, qualified by the catalog: `calc.double(...)`.
fn name(&self) -> &str {
"double"
}
fn metadata(&self) -> FunctionMetadata {
FunctionMetadata {
description: "Doubles a BIGINT".to_string(),
// The declared output type. A function whose output depends on its
// input leaves this off and decides in on_bind instead.
return_type: Some(DataType::Int64),
..Default::default()
}
}
/// The signature. `column` means the value arrives per row; the type is
/// named as an Arrow type string, so `int64` rather than SQL's `bigint`.
fn argument_specs(&self) -> Vec<ArgSpec> {
vec![ArgSpec::column("n", 0, "int64", "Value to double")]
}
/// Called once per input BATCH, not per row.
fn process(&self, params: &ProcessParams, batch: &RecordBatch) -> Result<RecordBatch> {
// The column arrives as the type the spec declared, so this downcast is
// safe. See the tutorial for what to do when it might not be.
let n = batch.column(0).as_primitive::<Int64Type>();
// One output value per input row. Null in, null out — collecting from
// Option is what carries that through.
let out: Int64Array = (0..n.len())
.map(|i| {
if n.is_valid(i) {
Some(n.value(i) * 2)
} else {
None
}
})
.collect();
RecordBatch::try_new(
params.output_schema.clone(),
vec![Arc::new(out) as ArrayRef],
)
.map_err(|e| RpcError::runtime_error(e.to_string()))
}
}
fn main() {
let mut worker = vgi::Worker::new();
worker.register_scalar(Double);
// Functions are served through a catalog, and its name is the name DuckDB
// ATTACHes. They must match: attaching under any other name fails.
worker.set_catalog(CatalogModel {
name: "calc".to_string(),
..Default::default()
});
worker.run();
}
Four things to notice:
- The trait is the function.
ScalarFunctionwantsname,metadata,argument_specsandprocess;on_bindhas a default and is only overridden when the return type depends on the argument types. - Argument types are Arrow type strings.
ArgSpec::column("n", 0, "int64", …)—int64, not SQL’sbigint. - A whole column at a time.
processreceives an ArrowRecordBatch, not one row, and returns a single-column batch of the same length. - The catalog name is the
ATTACHname. They are not independent: attaching under any other name fails.
Apache Arrow is a language-independent columnar memory format.
Rather than rows of objects, data lives in arrays: a contiguous, typed sequence of values for a
single column. VGI hands your function a whole column, and operating on it at once is what keeps it
fast across the process boundary. In Rust you work with these through
arrow-rs — RecordBatch is a chunk of a table, and
Int64Array is one int64 column of it.
Step 2 — Build it
Section titled “Step 2 — Build it”cargo build --release
A VGI worker is a normal binary, and a Rust one has no runtime to ship alongside it — the whole
worker is target/release/calcscalar.
Step 3 — Start Haybarn
Section titled “Step 3 — Start Haybarn”VGI functions run inside a DuckDB-compatible engine. We’ll use Haybarn — start it from the folder holding the binary you just built:
npx haybarn@rc
This opens Haybarn’s in-memory SQL shell — the memory H prompt. (First run downloads the CLI; see
other install options.)
Haybarn distributes the vgi extension through its own channel, so INSTALL vgi FROM community;
works out of the box. vgi isn’t in DuckDB’s public community repository, so stock DuckDB can’t
INSTALL it today — Haybarn is the supported path. The extension is the same one Python, Go, Rust
and TypeScript workers all talk to.
Step 4 — Attach and call it
Section titled “Step 4 — Attach and call it”At the memory H prompt:
INSTALL vgi FROM community;
LOAD vgi;
ATTACH 'calc' (TYPE vgi, LOCATION './target/release/calcscalar');
Now call it:
SELECT calc.double(21);
Output
| double(21) |
|---|
| 42 |
…or over a whole column:
SELECT calc.double(n) FROM (VALUES (1), (2), (3)) AS t(n);
Output
| double(n) |
|---|
| 2 |
| 4 |
| 6 |
Nulls pass straight through, because collecting from Option carries them:
SELECT calc.double(n) FROM (VALUES (5), (NULL)) AS t(n);
Output
| double(n) |
|---|
| 10 |
What just happened: ATTACH launched ./target/release/calcscalar as a subprocess and
registered calc.double in your SQL session. DuckDB handed your Rust the n column as a single
Arrow array, process ran over the whole thing, and the result streamed back — no row-by-row round
trips. Swap the body for any Rust you like and the SQL above doesn’t change.
You’ve built and run your first VGI function in Rust. 🎉
Troubleshootingerror[E0053]: method process has an incompatible type for trait— thevgi-rpcversion mismatch above. Pinvgi-rpc = "0.21"on 0.29.0.IO Error: VGI worker not found or not executable—LOCATIONis resolved from the directory the engine was started in. Check the path, and thatcargo build --releaseproduced the binary.No worker handles catalog 'x'— the name inATTACHmust equal theCatalogModel.name. It is not a free alias.- The
ATTACHhangs — run the binary directly. It speaks Arrow over stdin/stdout, so it looks like it hangs waiting for input; you’re checking for a startup panic on stderr. Catalog Error: Scalar Function with name double does not exist!— the name comes fromname(), qualified by the catalog name.
Next steps
Section titled “Next steps”- 2. Your first table function — generate rows instead of transforming them.
- The other function shapes → Function lifecycle.
- The exact contracts → Package overview and Scalar functions.