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.
What's a “worker”?A worker is a small Python 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.
series(3) → a three-row table 0, 1, 2. This generator is stateless — it builds every row
from its argument in one pass. (For results too large to build at once, the function patterns
guide shows the streaming-state
version.)
series just counts, but a table function’s process can emit rows from anything — a file
format DuckDB can’t read, a paginated HTTP API, a custom data source — and the rows stream straight
into a SQL FROM clause.
Step 1 — Grow the worker
Section titled “Step 1 — Grow the worker”Add a Series generator alongside the Double from step 1 — it produces rows from a count
argument. Save the whole file as calc_worker.py:
# /// script
# requires-python = ">=3.13"
# dependencies = ["vgi-python"]
# ///
"""The full tutorial worker: a scalar function and a table function.
Run from a DuckDB-compatible engine (Haybarn shown here)::
uvx haybarn-cli
ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_worker.py');
SELECT calc.double(21); -- scalar -> 42
SELECT * FROM calc.series(3); -- table -> 0, 1, 2
"""
from dataclasses import dataclass
from typing import Annotated, ClassVar
import pyarrow as pa
import pyarrow.compute as pc
from vgi import Arg, Param, Returns, ScalarFunction, Worker
from vgi.catalog import Catalog, Schema
from vgi.table_function import (
OutputCollector,
ProcessParams,
TableFunctionGenerator,
bind_fixed_schema,
init_single_worker,
)
class Double(ScalarFunction):
"""Double each input value (one row in, one row out)."""
@classmethod
def compute(
cls,
value: Annotated[pa.Int64Array, Param(doc="Values to double")],
) -> Annotated[pa.Int64Array, Returns()]:
"""Multiply the whole column by 2."""
return pc.multiply(value, 2)
@dataclass(slots=True, frozen=True, kw_only=True)
class SeriesArgs:
"""Arguments for :class:`Series` (one positional ``count``)."""
count: Annotated[int, Arg(0, doc="How many numbers to generate", ge=0)]
@init_single_worker
@bind_fixed_schema
class Series(TableFunctionGenerator[SeriesArgs]):
"""Generate the integers ``0 .. count-1`` as a one-column table.
Stateless: it emits every row in a single ``process`` call, then finishes.
``@bind_fixed_schema`` derives the output schema from ``FIXED_SCHEMA``;
``@init_single_worker`` runs the generator in a single worker.
"""
FIXED_SCHEMA: ClassVar[pa.Schema] = pa.schema([("n", pa.int64())])
@classmethod
def process(cls, params: ProcessParams[SeriesArgs], state: None, out: OutputCollector) -> None:
"""Emit all rows at once, then signal completion."""
out.emit(pa.RecordBatch.from_pydict({"n": list(range(params.args.count))}, schema=params.output_schema))
out.finish()
class CalcWorker(Worker):
"""A worker exposing the ``calc`` catalog with both functions."""
catalog = Catalog(
name="calc",
schemas=[Schema(name="main", functions=[Double, Series])],
)
if __name__ == "__main__":
CalcWorker().run()
Four things to notice about the new Series code:
- Typed arguments.
SeriesArgsdeclarescountas positional arg0;ge=0rejects negatives at bind time. - No state.
TableFunctionGenerator[SeriesArgs]takes just the argument type — this generator remembers nothing between calls. - Schema up front.
@bind_fixed_schemapublishesFIXED_SCHEMA(oneint64columnn);@init_single_workerruns it in one process. - Emit, then finish.
processbuilds the column fromcount, callsout.emit(batch)once, thenout.finish()to close the stream.
Unlike a scalar, a table function is pulled: DuckDB keeps calling process until you signal
out.finish() — that’s why it’s a generator.
A table function is pulled, not called: the engine asks the worker for output until it signals
completion. process is the pull handler — emit a batch to yield rows, call out.finish() when
there are no more. Because this one produces everything in a single process call, it never needs
to remember anything between calls, which is what stateless means here.
Step 2 — Attach and call it
Section titled “Step 2 — Attach and call it”Start a fresh Haybarn shell as in step 1
(npx haybarn@rc, from the folder holding calc_worker.py) — fresh, so the calc name is still
free. At the memory H prompt, load vgi and attach the grown worker:
INSTALL vgi FROM community;
LOAD vgi;
ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_worker.py');
Continuing from the scalar tutorial in the same shell? Its
calc is still the scalar-only worker, so series won’t exist yet — run DETACH calc; first,
then the attach above to load calc_worker.py.
The catalog is still calc; the new function is series. Table functions are called in the FROM
clause:
Yes at the SQL level: VGI table functions live in FROM, just like DuckDB table functions such as
read_parquet(...) or range(...). The difference is implementation: DuckDB pulls rows from your
Python worker over Arrow instead of running a built-in C++ table function.
SELECT * FROM calc.series(3);
Output
| n |
|---|
| 0 |
| 1 |
| 2 |
The scalar function is still there too — one worker, both functions:
SELECT calc.double(n) AS doubled FROM calc.series(3);
Output
| doubled |
|---|
| 0 |
| 2 |
| 4 |
What just happened: one calc worker now serves two functions. series ran in the FROM
clause — DuckDB pulled rows from your process method until it called out.finish() — and double
transformed them, all in the same Python process. Point process at a file, an API, or a query and
you have a custom table source DuckDB reads like any other.
You’ve grown the worker into a two-function catalog. 🎉
TroubleshootingFailed to attach database: database with name "calc" already exists—calcis still attached (from step 1, or you ranATTACHtwice). RunDETACH calc;and attach again, or open a fresh Haybarn shell.Binder Error: Function "series" is a table function but it was used as a scalar function.— table functions go inFROM, notSELECT:SELECT * FROM calc.series(3), notSELECT calc.series(3).Invalid Input Error: VGI Worker Exception: ArgumentValidationError: Argument '0' is too small.— that’s thege=0constraint doing its job; pass a non-negativecount. The full error names the argument, the value you passed, the constraint, and the arg’sdocstring.- Empty result —
series(0)is legitimately zero rows. Tryseries(5).
Next: Explore the five function patterns → — table-in-out, aggregate, and buffering shapes, each with a runnable worker.