Skip to content
Query.Farm
Talk with Us

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.

table shape
args → N rows

A table-valued source: scalar arguments in, a whole set of rows out — the second of the five function shapes.

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

Why this is powerful

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.

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:

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. SeriesArgs declares count as positional arg 0; ge=0 rejects negatives at bind time.
  • No state. TableFunctionGenerator[SeriesArgs] takes just the argument type — this generator remembers nothing between calls.
  • Schema up front. @bind_fixed_schema publishes FIXED_SCHEMA (one int64 column n); @init_single_worker runs it in one process.
  • Emit, then finish. process builds the column from count, calls out.emit(batch) once, then out.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.

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

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:

Same SQL shape, external implementation

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

Troubleshooting
  • Failed to attach database: database with name "calc" already existscalc is still attached (from step 1, or you ran ATTACH twice). Run DETACH 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 in FROM, not SELECT: SELECT * FROM calc.series(3), not SELECT calc.series(3).
  • Invalid Input Error: VGI Worker Exception: ArgumentValidationError: Argument '0' is too small. — that’s the ge=0 constraint doing its job; pass a non-negative count. The full error names the argument, the value you passed, the constraint, and the arg’s doc string.
  • Empty resultseries(0) is legitimately zero rows. Try series(5).

Next: Explore the five function patterns → — table-in-out, aggregate, and buffering shapes, each with a runnable worker.