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 JDK 21+.
What's a “worker”?A worker is a small program DuckDB talks to over Apache Arrow. It exposes one or more typed functions, and DuckDB calls them like built-ins. It is an ordinary JVM program — nothing is compiled into DuckDB, and nothing links against it.
upper_case is intentionally trivial — DuckDB can already uppercase a string. The point is that
compute is ordinary Java: reach for a Maven dependency, a model, an HTTP client, a parser
DuckDB has never heard of, and DuckDB calls it like a native SQL function.
Step 1 — The build file
Section titled “Step 1 — The build file”A worker needs one dependency, and three build settings that are not optional.
plugins { application }
repositories { mavenCentral() }
dependencies {
implementation("farm.query:vgi:0.26.1")
runtimeOnly("org.slf4j:slf4j-simple:2.0.16") // any SLF4J binding
}
tasks.withType<JavaCompile> {
// Required. See the warning below.
options.compilerArgs.add("-parameters")
}
application {
mainClass.set("farm.query.vgi.examples.ScalarExample")
applicationDefaultJvmArgs = listOf(
"--add-opens=java.base/java.nio=ALL-UNNAMED", // Arrow needs nio access
"--enable-native-access=ALL-UNNAMED", // shared-memory transport
)
}
The annotation-driven API reads your parameter names to build the SQL signature. Java erases
those by default, and without -parameters the worker still starts, still registers, and still
answers positional calls — so nothing looks wrong:
SELECT arg_name FROM vgi_function_arguments() WHERE function_name = 'upper_case';
Built with -parameters | Built without |
|---|---|
value | arg0 |
upper_case('hello') keeps working either way. What breaks is everything that depends on the name:
upper_case(value := 'hello') stops resolving, and any agent or tool reading the catalog sees
arg0. Nothing warns you, so put the flag in the build file before you write the function.
Step 2 — Write the worker
Section titled “Step 2 — Write the worker”// VGI-Java example: a scalar function.
//
// A scalar function maps each input row to one output row. You extend
// `ScalarFn` and write a single `compute()` method; the framework reads its
// parameter annotations to derive the SQL signature, the output type, and the
// per-batch dispatch. There is no schema boilerplate to write by hand.
//
// Run it on its own:
// ./gradlew runScalar --args="--unix /tmp/scalar.sock --idle-timeout 60"
// then from Haybarn:
// ATTACH 'demo' AS demo (TYPE vgi, LOCATION 'launch:/abs/path/bin/runScalar');
// SELECT demo.upper_case('hello'); -- HELLO
package farm.query.vgi.examples;
import farm.query.vgi.Worker;
import farm.query.vgi.scalar.ScalarFn;
import farm.query.vgi.scalar.Vector;
import org.apache.arrow.vector.VarCharVector;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
/** {@code upper_case(value VARCHAR) -> VARCHAR}: ASCII/Unicode uppercase. */
public final class ScalarExample extends ScalarFn {
@Override public String name() { return "upper_case"; }
@Override public String description() { return "Uppercase a string"; }
// One `@Vector` input column + one trailing (unannotated) output vector.
// The framework allocates `result`, sized to the batch row count, and
// writes whatever you put into it back across the wire.
//
// Parameter rules in one breath:
// @Vector -> a per-row input column (the Arrow vector type is the SQL type)
// @Const -> a bind-time constant arg (long/double/String/boolean/byte[])
// @Setting -> a session setting (SET demo.foo = ...)
// last unannotated vector = the output (framework-allocated)
public void compute(@Vector VarCharVector value, VarCharVector result) {
int rows = value.getValueCount();
result.allocateNew();
for (int i = 0; i < rows; i++) {
if (value.isNull(i)) { result.setNull(i); continue; }
String up = new String(value.get(i), StandardCharsets.UTF_8).toUpperCase(Locale.ROOT);
byte[] bytes = up.getBytes(StandardCharsets.UTF_8);
result.setSafe(i, bytes, 0, bytes.length);
}
}
public static void main(String[] args) {
Worker.builder()
.catalogName("demo")
.registerScalar(new ScalarExample())
.runFromArgs(args); // handles --unix / --http / --idle-timeout / stdio
}
}
Four things to notice:
- The method signature is the SQL signature.
@Vectormarks a per-row input column, and the Java vector type is the SQL type. There is no separate spec to keep in sync. - The last unannotated vector is the output. The framework allocates it, sized to the batch, and sends back whatever you write into it.
- A whole column at a time.
computeruns once per batch, not once per row. runFromArgspicks the transport. One entry point handles stdio,--unixand--http, which is why the same binary works for every deployment below.
Apache Arrow is a language-independent columnar memory format.
Rather than rows of objects, data lives in vectors: 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 Java these are the org.apache.arrow.vector.* types —
VarCharVector is one UTF-8 column.
Step 3 — Build it
Section titled “Step 3 — Build it”./gradlew installDist
That produces a self-contained launch script under
build/install/<project>/bin/<project> — the JVM flags from the build file are baked into it, which
is why ATTACH can point straight at it.
Step 4 — Attach and call it
Section titled “Step 4 — Attach and call it”Start Haybarn from anywhere and attach the launch script:
INSTALL vgi FROM community;
LOAD vgi;
ATTACH 'demo' (TYPE vgi, LOCATION 'launch:/abs/path/build/install/demo/bin/demo');
A cold JVM takes seconds to start. A bare LOCATION spawns a fresh one for every query, which
makes an interactive session unusable. The launch: prefix starts the worker once and reuses it
across queries over a flock-coordinated Unix socket.
The consequence to remember: after you rebuild, the pooled worker is still the old one. It will
keep answering with the previous build until it idles out or you kill it, which produces the
memorable experience of a fix that appears not to work. DETACH, then kill the JVM, then re-attach.
Now call it:
SELECT demo.upper_case('hello');
Output
| upper_case('hello') |
|---|
| HELLO |
…and by name, which is what -parameters bought you:
SELECT demo.upper_case(value := 'hello');
Output
| upper_case(value := 'hello') |
|---|
| HELLO |
What just happened: ATTACH launched the worker and registered demo.upper_case in your SQL
session. DuckDB handed your Java a whole VarCharVector, compute ran over it, and the result came
back across the wire — no row-by-row round trips. Swap the body for any Java you like and the SQL
above doesn’t change.
You’ve built and run your first VGI function in Java. 🎉
Troubleshooting- The argument is called
arg0— the-parametersflag is missing from the build. - Your change had no effect — the pooled
launch:worker is still the old build. Kill the JVM. IllegalAccessErrormentioningjava.nio— the--add-opensflag is missing.ATTACHtakes seconds every time — you used a bareLOCATIONinstead oflaunch:.No worker handles catalog 'x'— the name inATTACHmust equalcatalogName(...).
Next steps
Section titled “Next steps”- 2. Your first table function — generate rows instead of transforming them.
- The other function shapes → Function patterns.
- The exact contracts → Package overview and Scalar functions.