2. Add a table function
The second tutorial step: add a table function that generates rows, so one worker serves both a scalar and a table function β about 10 minutes, picking up from step 1.
series(3) β a three-row table 0, 1, 2. Unlike a scalar, a table function is pulled: DuckDB
calls Process repeatedly until the generator reports it has nothing left.
series just counts, but Process can emit rows from anything β a file format DuckDB canβt
read, a paginated HTTP API, a hardware device β and the rows stream straight into a SQL FROM clause.
Step 1 β Grow the worker
Section titled βStep 1 β Grow the workerβAdd a SeriesFn alongside the DoubleFn from step 1. Save the whole file as main.go:
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
// Command calc is the worker built across the vgi-go tutorial: one scalar
// function and one table function in a single catalog.
//
// The scalar `double` transforms a column in place. The table function `series`
// *generates* rows from an argument, so it is called in a FROM clause rather
// than an expression. One worker can serve any mix of shapes.
//
// go build -o calc .
// # then, in a Haybarn shell:
// ATTACH 'calc' (TYPE vgi, LOCATION './calc');
// SELECT calc.double(21);
// SELECT * FROM calc.series(3);
package main
import (
"context"
"flag"
"log"
"github.com/Query-farm/vgi-go/vgi"
"github.com/Query-farm/vgi-rpc-go/vgirpc"
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
)
// ββ scalar: double(n) βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
type doubleArgs struct {
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 {
return vgi.FunctionMetadata{
Description: "Doubles a BIGINT",
Stability: vgi.StabilityConsistent,
ReturnType: arrow.PrimitiveTypes.Int64,
}
}
func (*DoubleFn) OnBindTyped(_ *doubleArgs, _ *vgi.BindParams) (*vgi.BindResponse, error) {
return vgi.BindResult(arrow.PrimitiveTypes.Int64)
}
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 scalar function.
func NewDouble() vgi.ScalarFunction {
return vgi.AsScalarFunction[doubleArgs](&DoubleFn{})
}
// ββ table: series(count) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
// seriesOutputSchema is fixed, so OnBind can return it without inspecting the
// call. A table function that shapes its output from its arguments would build
// the schema here instead.
var seriesOutputSchema = arrow.NewSchema([]arrow.Field{
{Name: "n", Type: arrow.PrimitiveTypes.Int64},
}, nil)
type seriesArgs struct {
Count int64 `vgi:"pos=0,ge=0,doc=How many numbers to generate"`
}
// seriesState is the per-scan cursor. Embedding BatchState gives the generator
// helpers the remaining/batch-size bookkeeping; anything else the function needs
// between Process calls goes alongside it.
type seriesState struct {
vgi.BatchState
}
// SeriesFn generates the integers 0..count-1.
type SeriesFn struct{}
// Compile-time proof that the shape is satisfied. Cheap, and it turns a missing
// method into a build error rather than a registration-time surprise.
var _ vgi.TypedTableFunc[seriesState] = (*SeriesFn)(nil)
func (*SeriesFn) Name() string { return "series" }
func (*SeriesFn) Metadata() vgi.FunctionMetadata {
return vgi.FunctionMetadata{
Description: "Generates the integers 0..count-1",
Stability: vgi.StabilityConsistent,
}
}
func (*SeriesFn) ArgumentSpecs() []vgi.ArgSpec { return vgi.DeriveArgSpecs(seriesArgs{}) }
func (*SeriesFn) OnBind(_ *vgi.BindParams) (*vgi.BindResponse, error) {
return vgi.BindSchema(seriesOutputSchema)
}
// NewState runs once per scan, after bind. This is where arguments are read:
// they are fixed for the whole scan, so decoding them per batch would be waste.
func (*SeriesFn) NewState(params *vgi.ProcessParams) (*seriesState, error) {
var args seriesArgs
if err := vgi.BindArgs(params.Args, &args); err != nil {
return nil, err
}
return &seriesState{BatchState: vgi.NewBatchState(args.Count, 1024)}, nil
}
// Process is called repeatedly until the state reports no rows remain β that is
// what makes a table function a *generator*. GenerateBatch handles the chunking
// and signals completion for you; the callback only fills `size` rows.
func (*SeriesFn) Process(_ context.Context, _ *vgi.ProcessParams, state *seriesState, out *vgirpc.OutputCollector) error {
return vgi.GenerateBatch(&state.BatchState, out, func(size int64) ([]arrow.Array, error) {
start := state.Index
return []arrow.Array{
vgi.BuildInt64Array(size, func(i int64) int64 { return start + i }),
}, nil
})
}
// NewSeries returns the registration-ready table function.
func NewSeries() vgi.TableFunction {
return vgi.AsTableFunction[seriesState](&SeriesFn{})
}
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 scalar and a table function"),
)
w.RegisterScalar(NewDouble())
w.RegisterTable(NewSeries())
if *httpMode {
if err := w.RunHttp("127.0.0.1:0"); err != nil {
log.Fatal(err)
}
return
}
w.RunStdio()
}
Five things to notice about the new series code:
- State is a separate type.
TypedTableFunc[S]is parameterised by its state, not its arguments β a table functionβs arguments are fixed for the whole scan, while its state changes between calls. - Arguments are read once, in
NewState. They canβt change mid-scan, so decoding them per batch would be waste.vgi.BindArgsfills the struct fromparams.Args. ge=0is enforced. The constraint travels into the registered spec, soseries(-1)fails at bind rather than looping or silently returning nothing.GenerateBatchdoes the chunking.BatchStatetracks how many rows remain and how big a batch should be; your callback only fillssizerows. Signalling completion is handled for you.- The compile-time assertion earns its line.
var _ vgi.TypedTableFunc[seriesState] = (*SeriesFn)(nil)turns a missing or mis-typed method into a build error instead of a registration-time surprise.
A table function is pulled, not called: the engine asks the worker for output until it signals
completion. Process is the pull handler. Because series knows up front how many rows it owes,
BatchState can answer βam I done?β without the function tracking it β but a generator reading a
paginated API would keep its cursor in the same state struct and decide for itself.
Step 2 β Build and attach
Section titled βStep 2 β Build and attachβgo build -o calc .
Start a fresh Haybarn shell (fresh, so the calc name is still free), then:
INSTALL vgi FROM community;
LOAD vgi;
ATTACH 'calc' (TYPE vgi, LOCATION './calc');
Continuing in the same shell from step 1? Its calc is still the scalar-only worker, so series
wonβt exist yet β run DETACH calc; first.
Step 3 β Call it
Section titled βStep 3 β Call itβTable functions are called in the FROM clause:
SELECT * FROM calc.series(3);
Output
| n |
|---|
| 0 |
| 1 |
| 2 |
The scalar function is still there β one worker, both functions, composed in one query:
SELECT calc.double(n) AS doubled FROM calc.series(3);
Output
| doubled |
|---|
| 0 |
| 2 |
| 4 |
Ask for more rows than fit in one batch and the chunking is invisible from SQL:
SELECT count(*), sum(n) FROM calc.series(5000);
Output
| count_star() | sum(n) |
|---|---|
| 5000 | 12497500 |
And the constraint does its job:
SELECT * FROM calc.series(-1);
Invalid Input Error: VGI Worker Exception: argument "count" (position 0): must be >= 0
What just happened: one calc worker now serves two functions. series ran in the FROM clause
β DuckDB pulled batches from Process until BatchState reported none remaining β and double
transformed them, all in the same Go process.
Youβve grown the worker into a two-function catalog. π
TroubleshootingBinder Error: Failed to attach database: database with name "calc" already existsβcalcis still attached. RunDETACH calc;or open a fresh shell.Function "series" is a table function but it was used as a scalar functionβ table functions go inFROM, notSELECT.- Empty result β
series(0)is legitimately zero rows. Tryseries(5). - The scan never ends β a generator that never decrements its remaining count is an infinite
stream.
GenerateBatchhandles that for you; a hand-rolledProcessmust signal completion itself.
Next steps
Section titled βNext stepsβ- The other three shapes β Function patterns β a runnable worker for table-in-out, aggregate, and buffering.
- When each callback fires β Function lifecycle.
- Exact contracts β Table functions.