Recursive iteration with RECURSE()
RECURSE() runs a step-by-step computation from a seed ROW, producing an array of rows that records each step — the FeatureQL equivalent of SQL's WITH RECURSIVE. Use it for sequences and running totals (iterative mode), or for walking trees and graphs by joining related entities at each hop (traversal mode).
Syntax
RECURSE() accepts a seed row and an inner query (same WITH + SELECT shape as TRANSFORM() ). The result type is always ARRAY(ROW<level, is_cycle, …your fields…>).
-- Chained form (most common)
<seed_row>.RECURSE(
[WITH ...]
SELECT ...
[WHILE <condition>]
[VIA <seed_field> BIND TO <feature> FOLLOW <feature>]
[MAX_LEVEL <n>]
)
-- Callable form
RECURSE(
<seed_row>
USING (
[WITH ...]
SELECT ...
[WHILE <condition>]
[VIA <seed_field> BIND TO <feature> FOLLOW <feature>]
[MAX_LEVEL <n>]
)
)| Clause | Purpose |
|---|---|
Seed ROW | Starting values for the anchor step (level 0). |
SELECT | Defines output fields for each step. |
WHILE | Stops recursion when false on rows from level 1 onward (anchor is always kept). |
VIA … BIND TO … FOLLOW … | Enables traversal mode: join to related data each step. All three must appear together. |
MAX_LEVEL | Hard cap on recursion depth (default 100). |
For signatures and parameter details, see the generated reference for RECURSE() .
Output shape
Every RECURSE() result includes two implicit fields:
| Field | Type | Meaning |
|---|---|---|
level | BIGINT | Step index: 0 is the anchor, then 1, 2, … |
is_cycle | BOOLEAN | true when traversal revisits a node already on the path; always false in iterative mode |
All other fields come from your inner SELECT. Seed fields are not copied into the output unless you select them — they only seed prev[field] at level 1.
SELECT
ROW(1 AS n, 'hello' AS tag).RECURSE(
SELECT
prev[n] + 1 AS n
WHILE n < 4
MAX_LEVEL 10
) AS result
;| RESULT VARCHAR |
|---|
| [{level: 0, is_cycle: false, n: 1}, {level: 1, is_cycle: false, n: 2}, {level: 2, is_cycle: false, n: 3}] |
Namespaces inside the inner query
Inside the SELECT body you have three ways to refer to values:
| Form | Iterative mode | Traversal mode | Meaning |
|---|---|---|---|
prev[field] | Yes | Yes | Values from the previous step (at level 1, the seed). |
step[field] | No (error) | Yes (level ≥ 1) | Fields from the row joined in at this step. |
| Bare names | Yes | Yes | Fields defined earlier in the same SELECT (lateral references), including in WHILE. |
Bracket syntax (prev[n], step[amount]) matches how FeatureQL dereferences other column sources (for example source[column] in EXTERNAL_COLUMNS()).
Level 0 (anchor)
The anchor row is always emitted:
- Iterative: level 0 is the seed values as you defined them.
- Traversal: level 0 carries seed fields forward; anything that depends on
step[field]isNULLbecause no join has run yet.
WHILE is evaluated only from level 1 onward. If the first recursive step would fail WHILE, you still get the anchor:
SELECT
ROW(10 AS n).RECURSE(
SELECT
prev[n] + 1 AS n
WHILE n < 5
MAX_LEVEL 10
) AS result
;| RESULT VARCHAR |
|---|
| [{level: 0, is_cycle: false, n: 10}] |
Iterative mode
Omit VIA, BIND TO, and FOLLOW. Each step updates state from prev[field] only — no join, no step[field], and is_cycle stays false.
A simple counter shows level, WHILE, and per-binding isolation (starting from 10 produces only the anchor because WHILE n < 5 fails immediately on the first recursive step):
WITH
seed_n := INPUT(BIGINT)
SELECT
seed_n,
ROW(seed_n AS n).RECURSE(
SELECT
prev[n] + 1 AS n
WHILE n < 5
MAX_LEVEL 10
) AS result
FOR
seed_n := BIND_VALUES(ARRAY[1, 10])
;| SEED_N BIGINT | RESULT VARCHAR |
|---|---|
| 1 | [{level: 0, is_cycle: false, n: 1}, {level: 1, is_cycle: false, n: 2}, {level: 2, is_cycle: false, n: 3}, {level: 3, is_cycle: false, n: 4}] |
| 10 | [{level: 0, is_cycle: false, n: 10}] |
Fibonacci illustrates multi-field recurrence and WHILE on a bare name (b refers to the value just computed in the same SELECT):
WITH
seed_b := INPUT(BIGINT)
SELECT
seed_b,
ROW(0 AS a, seed_b AS b).RECURSE(
SELECT
prev[b] AS a,
prev[a] + prev[b] AS b
WHILE b < 100
MAX_LEVEL 20
) AS result
FOR
seed_b := BIND_VALUES(ARRAY[1, 3])
;| SEED_B BIGINT | RESULT VARCHAR |
|---|---|
| 1 | [{level: 0, is_cycle: false, a: 0, b: 1}, {level: 1, is_cycle: false, a: 1, b: 1}, {level: 2, is_cycle: false, a: 1, b: 2}, {level: 3, is_cycle: false, a: 2, b: 3}, {level: 4, is_cycle: false, a: 3, b: 5}, {level: 5, is_cycle: false, a: 5, b: 8}, {level: 6, is_cycle: false, a: 8, b: 13}, {level: 7, is_cycle: false, a: 13, b: 21}, {level: 8, is_cycle: false, a: 21, b: 34}, {level: 9, is_cycle: false, a: 34, b: 55}, {level: 10, is_cycle: false, a: 55, b: 89}] |
| 3 | [{level: 0, is_cycle: false, a: 0, b: 3}, {level: 1, is_cycle: false, a: 3, b: 3}, {level: 2, is_cycle: false, a: 3, b: 6}, {level: 3, is_cycle: false, a: 6, b: 9}, {level: 4, is_cycle: false, a: 9, b: 15}, {level: 5, is_cycle: false, a: 15, b: 24}, {level: 6, is_cycle: false, a: 24, b: 39}, {level: 7, is_cycle: false, a: 39, b: 63}] |
For a realistic iterative pattern, loan amortization carries rate, payment, period, and balance across steps until the balance reaches zero:
WITH
CLIENTS := ENTITY(),
client_id := INPUT(BIGINT#CLIENTS),
client_source := EXTERNAL_COLUMNS(
client_id BIGINT#CLIENTS BIND TO client_id,
initial_balance DECIMAL(10,2)
FROM TABLE(recurse_test.clients)
),
initial_balance := client_source[initial_balance]
SELECT
client_id,
ROW(0.01 AS rate, 500.00 AS payment, 0 AS period, initial_balance AS balance).RECURSE(
SELECT
prev[rate] AS rate,
prev[payment] AS payment,
prev[period] + 1 AS period,
prev[balance] + prev[balance] * prev[rate] - prev[payment] AS balance
WHILE balance > 0
MAX_LEVEL 360
) AS result
FOR
client_id := BIND_VALUES(ARRAY[1, 2])
;| CLIENT_ID BIGINT | RESULT VARCHAR |
|---|---|
| 1 | [{level: 0, is_cycle: false, rate: 0.01, payment: 500.0, period: 0, balance: 1000.0}, {level: 1, is_cycle: false, rate: 0.01, payment: 500.0, period: 1, balance: 510.0}, {level: 2, is_cycle: false, rate: 0.01, payment: 500.0, period: 2, balance: 15.1}] |
| 2 | [{level: 0, is_cycle: false, rate: 0.01, payment: 500.0, period: 0, balance: 400.0}] |
You can also use the callable form RECURSE(ROW(...) USING (...)) when a chained call is awkward:
SELECT
RECURSE(
ROW(1 AS n)
USING (
SELECT
prev[n] + 1 AS n
WHILE n < 4
MAX_LEVEL 10
)
) AS result
;| RESULT VARCHAR |
|---|
| [{level: 0, is_cycle: false, n: 1}, {level: 1, is_cycle: false, n: 2}, {level: 2, is_cycle: false, n: 3}] |
MAX_LEVEL stops recursion even without WHILE (here: seed plus three recursive steps → four rows):
SELECT
ROW(1 AS n).RECURSE(
SELECT
prev[n] + 1 AS n
MAX_LEVEL 3
) AS result
;| RESULT VARCHAR |
|---|
| [{level: 0, is_cycle: false, n: 1}, {level: 1, is_cycle: false, n: 2}, {level: 2, is_cycle: false, n: 3}, {level: 3, is_cycle: false, n: 4}] |
Expressions like CASE WHEN inside the SELECT body do not interfere with the top-level WHILE clause:
SELECT
ROW(1 AS n, 'start' AS label).RECURSE(
SELECT
prev[n] + 1 AS n,
CASE WHEN prev[n] + 1 > 3 THEN 'big' ELSE 'small' END AS label
WHILE n < 6
MAX_LEVEL 10
) AS result
;| RESULT VARCHAR |
|---|
| [{level: 0, is_cycle: false, n: 1, label: start}, {level: 1, is_cycle: false, n: 2, label: small}, {level: 2, is_cycle: false, n: 3, label: small}, {level: 3, is_cycle: false, n: 4, label: big}, {level: 4, is_cycle: false, n: 5, label: big}] |
Traversal mode
Add VIA <seed_field> BIND TO <feature> FOLLOW <feature> to join related data at each step. The grammar does not distinguish "tree" vs "graph" — FeatureQL infers the join strategy from entity grain annotations on the BIND TO and FOLLOW features:
| Pattern | Typical BIND TO | Typical FOLLOW | Use case |
|---|---|---|---|
| Walk up a hierarchy | Primary key on entity E | FK on E referencing E | Category → parent category |
| Walk down (fan-out) | FK on E referencing E | PK on E | Root → all descendants |
| Graph / edge table | FK on edge entity | Other FK on same edge entity | Transfers from_id → to_id |
step[field] resolves FeatureQL features on the traversed grain (including computed features like category_label := UPPER(category_name)), not only raw table columns.
Walk up a tree
Starting from a leaf category, each step follows parent_category_id to the parent row. Level 0 has NULL for step-derived fields; from level 1 onward you see id, name, and a label derived from step[category_label]:
WITH
cat_id := INPUT(BIGINT),
cat_source := EXTERNAL_COLUMNS(
category_id BIGINT BIND TO cat_id,
category_name VARCHAR,
parent_category_id BIGINT
FROM TABLE(recurse_test.categories)
),
category_id := cat_source[category_id],
category_name := cat_source[category_name],
category_label := UPPER(category_name),
parent_category_id := cat_source[parent_category_id]
SELECT
cat_id,
ROW(cat_id AS start).RECURSE(
WITH
lower_label := LOWER(step[category_label]),
SELECT
step[category_id] AS id,
step[category_name] AS name,
lower_label AS label
VIA start BIND TO category_id
FOLLOW parent_category_id
MAX_LEVEL 10
) AS result
FOR
cat_id := BIND_VALUES(ARRAY[4, 6])
;| CAT_ID BIGINT | RESULT VARCHAR |
|---|---|
| 4 | [{level: 0, is_cycle: false, id: NULL, name: NULL, label: NULL}, {level: 1, is_cycle: false, id: 4, name: Smartphones, label: smartphones}, {level: 2, is_cycle: false, id: 3, name: Phones, label: phones}, {level: 3, is_cycle: false, id: 2, name: Electronics, label: electronics}, {level: 4, is_cycle: false, id: 1, name: Root, label: root}] |
| 6 | [{level: 0, is_cycle: false, id: NULL, name: NULL, label: NULL}, {level: 1, is_cycle: false, id: 6, name: Shoes, label: shoes}, {level: 2, is_cycle: false, id: 5, name: Clothing, label: clothing}, {level: 3, is_cycle: false, id: 1, name: Root, label: root}] |
Walk down (fan-out)
Reverse the join: BIND TO parent_category_id and FOLLOW category_id. One parent can produce multiple children at the same level (notice two rows at level: 1 from Root):
WITH
cat_id := INPUT(BIGINT),
cat_source := EXTERNAL_COLUMNS(
category_id BIGINT BIND TO cat_id,
category_name VARCHAR,
parent_category_id BIGINT
FROM TABLE(recurse_test.categories)
),
category_id := cat_source[category_id],
category_name := cat_source[category_name],
parent_category_id := cat_source[parent_category_id]
SELECT
cat_id,
ROW(cat_id AS start).RECURSE(
SELECT
step[category_id] AS id,
step[category_name] AS name
VIA start BIND TO parent_category_id
FOLLOW category_id
MAX_LEVEL 10
) AS result
FOR
cat_id := BIND_VALUE(1)
;| CAT_ID BIGINT | RESULT VARCHAR |
|---|---|
| 1 | [{level: 0, is_cycle: false, id: NULL, name: NULL}, {level: 1, is_cycle: false, id: 2, name: Electronics}, {level: 1, is_cycle: false, id: 5, name: Clothing}, {level: 2, is_cycle: false, id: 3, name: Phones}, {level: 2, is_cycle: false, id: 6, name: Shoes}, {level: 3, is_cycle: false, id: 4, name: Smartphones}] |
Graph traversal and cycles
For edge tables (transfers, referrals, routes), declare the edge entity and bind its primary key in FOR with NESTED <pk> := BIND_VALUES(...) so step features are materialized for all edges. Accumulate step[amount] along the path; use WHILE to cap totals:
WITH
ACCOUNTS := ENTITY(),
TRANSFERS := ENTITY(),
account_id := INPUT(BIGINT#ACCOUNTS),
transfer_id := INPUT(BIGINT#TRANSFERS),
account_source := EXTERNAL_COLUMNS(
account_id BIGINT#ACCOUNTS BIND TO account_id,
account_name VARCHAR
FROM TABLE(recurse_test.accounts)
),
transfer_source := EXTERNAL_COLUMNS(
transfer_id BIGINT#TRANSFERS BIND TO transfer_id,
from_id BIGINT#ACCOUNTS,
to_id BIGINT#ACCOUNTS,
amount DOUBLE
FROM TABLE(recurse_test.transfers)
),
amount := transfer_source[amount],
from_id := transfer_source[from_id],
to_id := transfer_source[to_id]
SELECT
account_id,
ROW(account_id AS start, 0e0 AS cumulated).RECURSE(
SELECT
step[amount] AS amt,
prev[cumulated] + amt AS cumulated
WHILE cumulated <=.2 500e0
VIA start BIND TO from_id
FOLLOW to_id
MAX_LEVEL 10
) AS result
FOR
account_id := BIND_VALUES(ARRAY[1, 3]),
NESTED transfer_id := BIND_VALUES(ARRAY[101, 102, 103, 104])
;| ACCOUNT_ID BIGINT | RESULT VARCHAR |
|---|---|
| 1 | [{level: 0, is_cycle: false, cumulated: 0.0, amt: NULL}, {level: 1, is_cycle: false, cumulated: 100.0, amt: 100.0}, {level: 2, is_cycle: false, cumulated: 300.0, amt: 200.0}, {level: 3, is_cycle: false, cumulated: 350.0, amt: 50.0}] |
| 3 | [{level: 0, is_cycle: false, cumulated: 0.0, amt: NULL}, {level: 1, is_cycle: false, cumulated: 50.0, amt: 50.0}, {level: 2, is_cycle: false, cumulated: 350.0, amt: 300.0}] |
When the graph contains a cycle, the row where a node would be revisited is still emitted with is_cycle: true (recursion stops along that branch afterward):
WITH
ACCOUNTS := ENTITY(),
TRANSFERS := ENTITY(),
account_id := INPUT(BIGINT#ACCOUNTS),
transfer_id := INPUT(BIGINT#TRANSFERS),
account_source := EXTERNAL_COLUMNS(
account_id BIGINT#ACCOUNTS BIND TO account_id,
account_name VARCHAR
FROM TABLE(recurse_test.accounts)
),
transfer_source := EXTERNAL_COLUMNS(
transfer_id BIGINT#TRANSFERS BIND TO transfer_id,
from_id BIGINT#ACCOUNTS,
to_id BIGINT#ACCOUNTS,
amount DOUBLE
FROM TABLE(recurse_test.transfers_with_cycle)
),
amount := transfer_source[amount],
from_id := transfer_source[from_id],
to_id := transfer_source[to_id]
SELECT
account_id,
ROW(account_id AS start, 0e0 AS cumulated).RECURSE(
SELECT
step[amount] AS amt,
prev[cumulated] + amt AS cumulated
VIA start BIND TO from_id
FOLLOW to_id
MAX_LEVEL 20
) AS result
FOR
account_id := BIND_VALUE(1),
NESTED transfer_id := BIND_VALUES(ARRAY[101, 102, 103, 104])
;| ACCOUNT_ID BIGINT | RESULT VARCHAR |
|---|---|
| 1 | [{level: 0, is_cycle: false, cumulated: 0.0, amt: NULL}, {level: 1, is_cycle: false, cumulated: 100.0, amt: 100.0}, {level: 2, is_cycle: false, cumulated: 300.0, amt: 200.0}, {level: 3, is_cycle: false, cumulated: 350.0, amt: 50.0}, {level: 4, is_cycle: false, cumulated: 650.0, amt: 300.0}, {level: 5, is_cycle: true, cumulated: 660.0, amt: 10.0}] |
Compose with other array operations
RECURSE() returns an array of rows, so you can pipe it into TRANSFORM() like any other ARRAY(ROW<…>) feature — for example, sum a field across all steps:
WITH
steps := ROW(1 AS n).RECURSE(
SELECT prev[n] + 1 AS n
WHILE n < 5
MAX_LEVEL 10
)
SELECT
steps.TRANSFORM(SELECT SUM(n)).UNWRAP_ONE() AS total
;| TOTAL BIGINT |
|---|
| 10 |
To run recursion from another entity's grain (for example, attach a category path to each product), define RECURSE() on the target grain and bring it in with EXTEND() — the recursion and the enclosing row stay decoupled.
Limitations
You cannot look up external data keyed on a value only known mid-recursion (for example, joining a rate table on prev[running_balance]). The lookup target is not fixed at plan time. Compute such enrichment after RECURSE() — for example with EXTEND() on the resulting array of rows.
step[field]in iterative mode is rejected; useprev[field]only.VIA,BIND TO, andFOLLOWmust all be present or all omitted.- Backend support: recursive CTE generation is implemented for DuckDB first; other SQL backends may raise
RECURSE-NOT-SUPPORTED-BACKENDuntil added.
Best practices
- Put state you want in the output in the inner
SELECT(re-select constants each step if they should appear on every row, as in the loan example). - Use
WHILEfor business stopping rules andMAX_LEVELas a safety cap. - For graphs, always
NESTEDbind the edge entity primary key inFORsostep[field]features are available for every edge row. - Prefer
RECURSE()+TRANSFORM()over trying to express multi-step array logic as nestedTRANSFORM()calls when each step depends on the previous step's output.
See also
- Array of rows overview — when nested rows replace joins
- TRANSFORM() — filter, aggregate, and sort within an array
- EXTEND() — join related features onto rows before or after recursion
- RECURSE() function reference — signatures and registry metadata