| description | How to write each of the five VGI function patterns: scalar, table, table-in-out, aggregate, and buffering — with a complete runnable worker for each. |
|---|
What this is: a recipe showing each of the five VGI function patterns, with a complete,
runnable worker for each.
Who it's for: developers who've finished the
tutorial and want to know which pattern fits their problem.
- You can build and run a worker (see the tutorial).
- Python 3.13+,
uv, and a DuckDB-compatible engine (Haybarn or stock DuckDB).
1 row → 1 value
args → N rows
N rows → M rows
N rows → 1 value
stream → [state] → stream
| Pattern | Shape | Use it when… | SQL |
|---|---|---|---|
| Scalar | 1 row → 1 row | you transform each row independently | SELECT f(col) FROM t |
| Table | args → N rows | you generate rows from arguments | SELECT * FROM f(args) |
| Table-in-out | N rows → M rows | you reshape/filter a streamed table | SELECT * FROM f((SELECT …)) |
| Aggregate | grouped rows → 1 row/group | you accumulate per GROUP BY group |
SELECT f(col) FROM t GROUP BY k |
| Buffering | stream → [state] → stream | you must see every row first (sort, top-k, full reduction) | SELECT * FROM f((SELECT …)) |
Runs on each row independently and returns a single value — a pure per-row transform. { .kind-banner__desc }
One row in, one row out. Operate on the whole column with pyarrow.compute; the Annotated
types are the schema.
--8<-- "examples/calc_scalar_worker.py"ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_scalar_worker.py');
SELECT calc.double(n) FROM (VALUES (1), (2), (3)) AS t(n);Scalars aren't just for numbers — any column type works. A string transform looks the same; here
compute joins Hello, + name + ! across the column:
--8<-- "examples/greeting_scalar_worker.py"SELECT greetings.greeting(name) FROM (VALUES ('Alice'), ('Bob')) AS t(name);A table-valued source: scalar arguments in, a whole set of rows out. { .kind-banner__desc }
Generate rows from arguments, no input table. Declare a typed args dataclass and a FIXED_SCHEMA;
process emits batches until out.finish(). The @bind_fixed_schema / @init_single_worker
decorators wire up the common single-worker lifecycle. (This is the full tutorial worker — the
scalar double plus the series generator.)
--8<-- "examples/calc_worker.py"ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_worker.py');
SELECT * FROM calc.series(3);The tutorial's series emits every row in a single process call. That's fine for small results,
but process is actually called repeatedly until you call out.finish() — so for large output
you emit a bounded chunk per call and remember your place in a small state object. The state
extends ArrowSerializableDataclass so it survives HTTP state round-trips:
--8<-- "examples/series_streaming_worker.py"SELECT * FROM calc.series(1000000); -- streamed CHUNK rows per process() callConsumes a relation and streams a transformed relation back, batch by batch. { .kind-banner__desc }
Stream an input table through, batch by batch, emitting transformed output. on_bind declares the
output schema; process receives each input batch and emits results. Here we keep only rows
whose value column is positive.
--8<-- "examples/filter_worker.py"ATTACH 'filters' (TYPE vgi, LOCATION 'uv run filter_worker.py');
SELECT * FROM filters.filter_positive((SELECT * FROM my_table));Folds many rows down into a single value per group. { .kind-banner__desc }
Accumulate input rows into per-group state, then emit one row per group. Aggregates are driven by
DuckDB's GROUP BY and run in three phases — update (fold a batch into per-group state),
combine (merge partial states across parallel workers), and finalize (state → output row).
--8<-- "examples/sum_worker.py"ATTACH 'aggregates' (TYPE vgi, LOCATION 'uv run sum_worker.py');
SELECT category, aggregates.vgi_sum(value) FROM t GROUP BY category;??? info "State must be serializable"
Table generators and aggregates keep state between calls. A state type must provide
serialize_to_bytes() and deserialize_from_bytes() so the framework can carry it across
HTTP round-trips or persist it between aggregate calls. ArrowSerializableDataclass
implements these methods for you and is the usual starting point. Custom state types can
implement the same [StreamStateCodec][vgi.function.StreamStateCodec] contract without
inheriting from ArrowSerializableDataclass. See
State Class for both approaches.
Holds every input row in state before emitting — the basis for sorts, top-k, and full-stream reductions. { .kind-banner__desc }
When a function must see the whole input before it can produce any output — a global sort,
top-k, or a full reduction — use a buffering function. Unlike table-in-out (which emits per input
batch), it runs in three phases: process (the sink — stash each batch, return a state_id),
combine (reduce all the partials once), and finalize (the source — stream the result out).
Because the phases can run in different worker processes, state can't live in memory — it goes in
params.storage, a shared store keyed to this call (its execution_id). See
Persist state across workers for the backends behind it.
--8<-- "examples/row_count_worker.py"ATTACH 'buffers' (TYPE vgi, LOCATION 'uv run row_count_worker.py');
SELECT * FROM buffers.row_count((SELECT * FROM big_table));The sink runs in parallel across DuckDB threads, so process sees batches in no particular order.
When output depends on input order — a running total, a row pattern match — set
Meta.requires_input_batch_index = True and each process call receives params.batch_index, a
monotonic ordinal you can sort the buffered batches by in finalize.
Not every source can supply one (a base table scan can; range() and VALUES cannot). When it
cannot, the extension serializes the sink and numbers the batches itself, so a worker always gets a
valid index and callers never have to wrap their input. This example reports the indices it saw, one
row per buffered batch:
--8<-- "examples/batch_index_worker.py"ATTACH 'bi' (TYPE vgi, LOCATION 'uv run batch_index_worker.py');
SELECT * FROM bi.batch_indexes((SELECT * FROM range(5000))) ORDER BY batch_index;The alternative, Meta.sink_order_dependent = True, delivers ordered input by forcing a
single-threaded sink. It works with any source but gives up parallel ingest, which is usually the
bulk of a buffering query's wall time. The two flags are mutually exclusive.
??? info "Buffering vs. table-in-out"
Both consume a relation, but a table-in-out function emits per input batch and never
holds the whole input — use it for streaming transforms (filter, enrich, reshape). Reach for
buffering only when output genuinely depends on every row. See
Persist state across workers for the storage backends params.storage uses.
- Persist state across workers → State storage.
- Let the optimizer prune work → Integrate with the optimizer.
- Understand the call lifecycle → Concepts.
- Exact signatures → API Reference: Functions.