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

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

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:

main.go
// 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. doubleArgs declares 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 int64 field becomes a BIGINT argument with no type= override needed.
  • A whole column at a time. ProcessTyped receives an Arrow RecordBatch, not one row. MapColumn walks the column and builds the result, propagating nulls for you.
  • Bind declares the output type. OnBindTyped runs once per query, before any data moves; that’s what lets DuckDB plan around your function.
`type=` takes Arrow names, not SQL names

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.

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 Go you work with these through arrow-goarrow.RecordBatch is a chunk of a table, and array.Int64 is one int64 column of 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 .
cgo is required

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.

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 and TypeScript workers all talk to.

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

Troubleshooting
  • IO Error: VGI worker not found or not executableLOCATION is resolved from the directory the engine was started in. Check the path, and that go build actually produced the binary.
  • type="bigint" is not a known Arrow type name (did you mean "int64"?) — the worker refuses to start. Delete the type= override and let the field type infer it. (On v0.21.0 and earlier this failed later instead, as No function matches … 'double(INTEGER_LITERAL)'.)
  • The ATTACH hangs — run ./calcscalar directly. 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 from Name(), qualified by the catalog name from ATTACH.