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>]
  )
)
ClausePurpose
Seed ROWStarting values for the anchor step (level 0).
SELECTDefines output fields for each step.
WHILEStops 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_LEVELHard 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:

FieldTypeMeaning
levelBIGINTStep index: 0 is the anchor, then 1, 2, …
is_cycleBOOLEANtrue 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.

FeatureQL
SELECT
    ROW(1 AS n, 'hello' AS tag).RECURSE(
        SELECT
            prev[n] + 1 AS n
        WHILE n < 4
        MAX_LEVEL 10
    ) AS result
;
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:

FormIterative modeTraversal modeMeaning
prev[field]YesYesValues 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 namesYesYesFields 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] is NULL because 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:

FeatureQL
SELECT
    ROW(10 AS n).RECURSE(
        SELECT
            prev[n] + 1 AS n
        WHILE n < 5
        MAX_LEVEL 10
    ) AS result
;
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):

FeatureQL
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])
;
Result
SEED_N BIGINTRESULT 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):

FeatureQL
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])
;
Result
SEED_B BIGINTRESULT 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:

FeatureQL
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])
;
Result
CLIENT_ID BIGINTRESULT 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:

FeatureQL
SELECT
    RECURSE(
        ROW(1 AS n)
        USING (
            SELECT
                prev[n] + 1 AS n
            WHILE n < 4
            MAX_LEVEL 10
        )
    ) AS result
;
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):

FeatureQL
SELECT
    ROW(1 AS n).RECURSE(
        SELECT
            prev[n] + 1 AS n
        MAX_LEVEL 3
    ) AS result
;
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:

FeatureQL
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
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:

PatternTypical BIND TOTypical FOLLOWUse case
Walk up a hierarchyPrimary key on entity EFK on E referencing ECategory → parent category
Walk down (fan-out)FK on E referencing EPK on ERoot → all descendants
Graph / edge tableFK on edge entityOther FK on same edge entityTransfers from_idto_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]:

FeatureQL
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])
;
Result
CAT_ID BIGINTRESULT 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):

FeatureQL
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)
;
Result
CAT_ID BIGINTRESULT 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:

FeatureQL
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])
;
Result
ACCOUNT_ID BIGINTRESULT 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):

FeatureQL
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])
;
Result
ACCOUNT_ID BIGINTRESULT 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:

FeatureQL
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
;
Result
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

What does not work inside RECURSE

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; use prev[field] only.
  • VIA, BIND TO, and FOLLOW must all be present or all omitted.
  • Backend support: recursive CTE generation is implemented for DuckDB first; other SQL backends may raise RECURSE-NOT-SUPPORTED-BACKEND until 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 WHILE for business stopping rules and MAX_LEVEL as a safety cap.
  • For graphs, always NESTED bind the edge entity primary key in FOR so step[field] features are available for every edge row.
  • Prefer RECURSE() + TRANSFORM() over trying to express multi-step array logic as nested TRANSFORM() calls when each step depends on the previous step's output.

See also

Last update at: 2026/05/26 17:22:09