Skip to content
Query.Farm
Talk with Us

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.

scalar shape
1 row → 1 value

Runs on each row independently and returns a single value — a pure per-row transform.

Why this is powerful

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.

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.

On vgi 0.29.0, pin vgi-rpc as well

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:

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. ScalarFunction wants name, metadata, argument_specs and process; on_bind has 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’s bigint.
  • A whole column at a time. process receives an Arrow RecordBatch, not one row, and returns a single-column batch of the same length.
  • The catalog name is the ATTACH name. They are not independent: attaching under any other name fails.
New to Apache Arrow?

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-rsRecordBatch is a chunk of a table, and Int64Array is one int64 column of 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.

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 serves the vgi extension

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.

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

Troubleshooting
  • error[E0053]: method process has an incompatible type for trait — the vgi-rpc version mismatch above. Pin vgi-rpc = "0.21" on 0.29.0.
  • IO Error: VGI worker not found or not executableLOCATION is resolved from the directory the engine was started in. Check the path, and that cargo build --release produced the binary.
  • No worker handles catalog 'x' — the name in ATTACH must equal the CatalogModel.name. It is not a free alias.
  • The ATTACH hangs — 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 from name(), qualified by the catalog name.