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 Go 1.25+ (the module’s declared minimum).
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 Go binary — nothing is compiled into DuckDB.
double is intentionally trivial — DuckDB can already do n * 2. The point is that ProcessTyped
is ordinary Go: drop in a library, a model, an HTTP 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 new module and add the SDK:
mkdir calcscalar && cd calcscalar
go mod init example.com/calcscalar
go get github.com/Query-farm/vgi-go
You’ll run go mod tidy after writing the file, below. go get fetches the SDK but not the other
modules the worker imports — arrow-go for the Arrow types, and
vgi-rpc-go for the output collector — so a build before tidying fails with a wall of
missing go.sum entry errors.
Then main.go. The DoubleFn type is the whole function; the rest wires it into a worker that
publishes the calc catalog:
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
// Command calcscalar is the worker built in step 1 of the vgi-go 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.
//
// go build -o calcscalar .
// # then, in a Haybarn shell:
// ATTACH 'calc' (TYPE vgi, LOCATION './calcscalar');
// SELECT calc.double(21);
package main
import (
"context"
"flag"
"log"
"github.com/Query-farm/vgi-go/vgi"
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
)
// doubleArgs declares the function's arguments. The `vgi:"..."` tags are the
// whole signature: they drive both the ArgumentSpecs the catalog advertises and
// the runtime binding of values into this struct, so the SQL signature and the
// Go type can never drift apart.
type doubleArgs struct {
// No type= needed: the Go field type infers the Arrow type (int64 here).
// When you do override it, the tag takes ARROW names ("int64"), not SQL
// names ("bigint") — an unrecognised name silently falls back to VARCHAR.
N int64 `vgi:"pos=0,const=false,doc=Value to double"`
}
// DoubleFn doubles each value in its input column.
type DoubleFn struct{}
func (*DoubleFn) Name() string { return "double" }
func (*DoubleFn) Metadata() vgi.FunctionMetadata {
fortyTwo := "42"
return vgi.FunctionMetadata{
Description: "Doubles a BIGINT",
Stability: vgi.StabilityConsistent,
ReturnType: arrow.PrimitiveTypes.Int64,
Examples: []vgi.CatalogExample{
{SQL: "SELECT calc.double(21)", Description: "Doubles a literal", ExpectedOutput: &fortyTwo},
},
}
}
// OnBindTyped runs once per query, before any data moves. Returning the output
// type here is what lets DuckDB plan the query.
func (*DoubleFn) OnBindTyped(_ *doubleArgs, _ *vgi.BindParams) (*vgi.BindResponse, error) {
return vgi.BindResult(arrow.PrimitiveTypes.Int64)
}
// ProcessTyped runs per input batch. MapColumn walks column 0 and builds the
// output column, preserving nulls — the row count out must equal the row count
// in, which is the contract that makes this a *scalar* function.
func (*DoubleFn) ProcessTyped(_ context.Context, _ *doubleArgs, params *vgi.ProcessParams, batch arrow.RecordBatch) (arrow.RecordBatch, error) {
return vgi.MapColumn(params, batch, 0, array.NewInt64Builder,
func(col arrow.Array, i int) int64 {
return vgi.GetInt64Value(col, i) * 2
})
}
// NewDouble returns the registration-ready function. Tests use this too, so the
// wiring under test is the wiring that ships.
func NewDouble() vgi.ScalarFunction {
return vgi.AsScalarFunction[doubleArgs](&DoubleFn{})
}
func main() {
httpMode := flag.Bool("http", false, "serve over HTTP instead of stdio")
logFlags := vgi.RegisterLoggingFlags(flag.CommandLine)
flag.Parse()
if err := logFlags.Apply(); err != nil {
log.Fatalf("logging flags: %v", err)
}
w := vgi.NewWorker(
vgi.WithCatalogName("calc"),
vgi.WithCatalogComment("Tutorial worker: a single scalar function"),
)
w.RegisterScalar(NewDouble())
if *httpMode {
if err := w.RunHttp("127.0.0.1:0"); err != nil {
log.Fatal(err)
}
return
}
w.RunStdio()
}
Four things to notice:
- Struct tags are the signature.
doubleArgsdeclares one positional, columnar argument. The tag drives both the spec DuckDB registers and the binding of runtime values into the struct, so the two cannot disagree. - The Go type infers the Arrow type. An
int64field becomes aBIGINTargument with notype=override needed. - A whole column at a time.
ProcessTypedreceives an ArrowRecordBatch, not one row.MapColumnwalks the column and builds the result, propagating nulls for you. - Bind declares the output type.
OnBindTypedruns once per query, before any data moves; that’s what lets DuckDB plan around your function.
If you do override the inferred type, the tag wants int64, not bigint — and timestamp_us,
not timestamp. A few SQL spellings happen to be valid (varchar, double, boolean, blob),
which is what makes the others tempting.
Since v0.22.0 an unrecognised name is rejected at registration, with the right one suggested. On
v0.21.0 and earlier it silently became VARCHAR, and the failure surfaced much later as a bind
error naming the call site rather than the tag. Either way the safe habit is to omit type= and
let the Go field type infer it, which is what this worker does.
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 Go you work with these through
arrow-go — arrow.RecordBatch is a chunk of a table, and
array.Int64 is one int64 column of it.
Step 2 — Build it
Section titled “Step 2 — Build it”A VGI worker is a normal binary, so ATTACH can point straight at it:
go mod tidy # resolve arrow-go and vgi-rpc-go
go build -o calcscalar .
The SDK depends on DuckDB’s Go bindings, so CGO_ENABLED=0 fails with
“build constraints exclude all Go files”. Keep cgo on — it is the default locally, but not in every
container or CI image. Expect a large binary (~100 MB); those bindings are most of it.
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 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 './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 MapColumn propagates them:
SELECT calc.double(n) FROM (VALUES (5), (NULL)) AS t(n);
Output
| double(n) |
|---|
| 10 |
What just happened: ATTACH launched ./calcscalar as a subprocess and registered calc.double
in your SQL session. DuckDB handed your Go code the n column as a single Arrow array,
ProcessTyped ran over the whole thing, and the result streamed back — no row-by-row round trips.
Swap the body for any Go you like and the SQL above doesn’t change.
You’ve built and run your first VGI function in Go. 🎉
TroubleshootingIO Error: VGI worker not found or not executable—LOCATIONis resolved from the directory the engine was started in. Check the path, and thatgo buildactually produced the binary.type="bigint" is not a known Arrow type name (did you mean "int64"?)— the worker refuses to start. Delete thetype=override and let the field type infer it. (On v0.21.0 and earlier this failed later instead, asNo function matches … 'double(INTEGER_LITERAL)'.)- The
ATTACHhangs — run./calcscalardirectly. The worker 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 fromATTACH.
Next steps
Section titled “Next steps”- The other function shapes → Function lifecycle — table, table-in-out, aggregate and buffering, and when each callback fires.
- The exact contracts → Package overview and Scalar functions.
- Why any of this → Concepts.