Array of rows overview
FeatureQL is entity-centric: each row in a query represents one entity (a customer, a product, an account). When an entity has many related facts — a customer's orders, a product's price history — those facts are stored as an array of rows inside the entity row rather than in a separate table.
This is the core data structure behind Entity Centric Modeling (ECM). Instead of joining across tables at query time, you keep everything nested within the entity and operate on it directly.
FeatureQL provides a rich set of operations for working with arrays of rows:
| Operation | Syntax | Description |
|---|---|---|
| Access rows | array[<index>] or array[<start>:<end>] | Extract one row or a range of rows |
| Access fields | row[a], row[b, d], row[*, -b] | One unaliased field is a scalar; a keep list, *, -name, or AS is a ROW |
| Unwrap | UNWRAP(array) / UNWRAP_ONE(array) | Flatten a single-field array of rows into a plain array; UNWRAP_ONE requires exactly one row |
| Concatenate | ARRAY_CONCAT(a, b DEDUPLICATE ON ...) | Append rows from two arrays, with optional deduplication (DuckDB only) |
| Zip | ZIP(array1 AS name1, array2 AS name2) | Combine separate arrays into an array of rows |
| Merge by position | ARRAY_MERGE(a, b) | Combine fields from two arrays row-by-row |
| Merge by key | ARRAY_MERGE(a, b VIA field) | Left-join rows on a shared field name |
| Merge by predicate | ARRAY_MERGE(a, b ON predicate) | Left-join rows with a general boolean condition |
| Carry | CARRY(scalar, ... INTO array) | Broadcast scalar values into each row as new fields |
| Extend | EXTEND(array WITH feature VIA ...) | Enrich rows by joining on an external feature |
| Transform | TRANSFORM(array USING (subquery)) | Map, filter, or aggregate the rows |
| Index | INDEX_UNIQUE(array BY field) / INDEX_MULTI(...) | Build an INDEX for fast lookups by field value |
| Recurse | ROW(...).RECURSE(RETURN ...) | Build an array of rows step by step (sequences, hierarchies, graphs) |
The full list of array functions is available in the Functions reference.
Compatibility with generic array functions
Most generic array functions accept arrays of rows. The following work directly on ARRAY<ROW> inputs: ARRAY_COUNT, ARRAY_DISTINCT, ARRAY_ENUMERATE, ARRAY_REVERSE, ARRAY_SORT, ARRAY_REMOVE, ARRAY_UNION, ARRAY_INTERSECT, ARRAY_EXCEPT, ARRAY_MIN, ARRAY_MAX, CONTAINS, REPEAT, ARRAY_POSITION, ELEMENT_AT_POS, ELEMENTS_AT_POS, ARRAYS_OVERLAP, CONTAINS_SEQUENCE, and FLATTEN.
When using the two-array set operations (ARRAY_UNION, ARRAY_INTERSECT, ARRAY_EXCEPT) or CONTAINS, both arrays — or the array and the search element — must share the exact same row schema (same field names and types). FeatureQL validates this at compile time and rejects mismatches with a clear error.
The following functions require scalar element types and will reject ARRAY<ROW> inputs: ARRAY_SUM, ARRAY_AVG, ARRAY_PRODUCT, ARRAY_MEDIAN, ARRAY_STDDEV_POP, ARRAY_JOIN, ARRAY_NOT, NORMALIZE, COSINE_SIMILARITY, ARRAY_ROTATE, and ARRAY_HISTOGRAM.
Creating arrays of rows
There are several ways to construct an array of rows. You can build it from ROW() literals, use ZIP() to combine separate arrays, or cast unnamed rows to give them field names.
RETURN
unnamed := ARRAY(ROW('D', 'E', 'F'), ROW('G', 'H', 'I')),
named := ARRAY(ROW('D' AS col1, 'E' AS col2), ROW('G' AS col1, 'H' AS col2)),
renamed := ARRAY(ROW(4, 'A'), ROW(5, 'B'), ROW(6, 'C'))::ARRAY<
ROW<num BIGINT, str VARCHAR>
>
;| UNNAMED ARRAY<ROW<field_1 VARCHAR, field_2 VARCHAR, field_3 VARCHAR>> | NAMED ARRAY<ROW<col1 VARCHAR, col2 VARCHAR>> | RENAMED ARRAY<ROW<num BIGINT, str VARCHAR>> |
|---|---|---|
| [{field_1: D, field_2: E, field_3: F}, {field_1: G, field_2: H, field_3: I}] | [{col1: D, col2: E}, {col1: G, col2: H}] | [{num: 4, str: A}, {num: 5, str: B}, {num: 6, str: C}] |
Accessing fields and rows
Use bracket notation to slice into an array of rows. A numeric index selects rows; a field name selects columns. You can chain both — for example, first pick specific rows, then extract a field — in either order.
RETURN
base := ARRAY(ROW(1, 'A'), ROW(2, 'B'), ROW(3, 'C'), ROW(4, 'D'))::ARRAY<
ROW<col1 BIGINT, col2 VARCHAR>
>,
single_row := base[2],
multiple_rows := base[2, 4],
range_rows := base[2:4],
multiple_fields := base[col2, col1],
multiple_fields_renamed := base[col2 AS field2, col1 AS field1],
field_then_row := base[col2][1, 3],
row_then_field := base[1, 3][col2]
;| BASE ARRAY<ROW<col1 BIGINT, col2 VARCHAR>> | SINGLE_ROW ROW<col1 BIGINT, col2 VARCHAR> | MULTIPLE_ROWS ARRAY<ROW<col1 BIGINT, col2 VARCHAR>> | RANGE_ROWS ARRAY<ROW<col1 BIGINT, col2 VARCHAR>> | MULTIPLE_FIELDS ARRAY<ROW<col2 VARCHAR, col1 BIGINT>> | MULTIPLE_FIELDS_RENAMED ARRAY<ROW<field2 VARCHAR, field1 BIGINT>> | FIELD_THEN_ROW ARRAY<VARCHAR> | ROW_THEN_FIELD ARRAY<VARCHAR> |
|---|---|---|---|---|---|---|---|
| [{col1: 1, col2: A}, {col1: 2, col2: B}, {col1: 3, col2: C}, {col1: 4, col2: D}] | {col1: 2, col2: B} | [{col1: 2, col2: B}, {col1: 4, col2: D}] | [{col1: 2, col2: B}, {col1: 3, col2: C}, {col1: 4, col2: D}] | [{col2: A, col1: 1}, {col2: B, col1: 2}, {col2: C, col1: 3}, {col2: D, col1: 4}] | [{field2: A, field1: 1}, {field2: B, field1: 2}, {field2: C, field1: 3}, {field2: D, field1: 4}] | [A, C] | [A, C] |
The field bracket is a left-to-right list of edits, not a keep-or-drop set. x and x AS y copy from the source row; * copies remaining source fields (skipping names already chosen); -x removes an output name that is already selected. R[a] stays a scalar. R[a AS x] and R[*, -b, -c, -d] stay a one-field row.
DEFINE
r := ROW(1 AS a, 2 AS b, 3 AS c, 4 AS d)
RETURN
drop_b := r[*, -b],
d_first := r[d, *],
copy_c := r[*, c AS c2]
;| DROP_B ROW<a BIGINT, c BIGINT, d BIGINT> | D_FIRST ROW<d BIGINT, a BIGINT, b BIGINT, c BIGINT> | COPY_C ROW<a BIGINT, b BIGINT, c BIGINT, d BIGINT, c2 BIGINT> |
|---|---|---|
| {a: 1, c: 3, d: 4} | {d: 4, a: 1, b: 2, c: 3} | {a: 1, b: 2, c: 3, d: 4, c2: 3} |
R[-b, *] is an error: -b runs against an empty result. Write R[*, -b]. An empty R[] is a syntax error.
Concatenating arrays of rows
ARRAY_CONCAT appends the rows of two arrays. When combining data from different sources, use DEDUPLICATE ON to keep only one row per key value. Rows from the second array take priority over the first; within a source, the last occurrence of a key wins. DEDUPLICATE ON is DuckDB-only.
DEFINE
base1 := ARRAY(ROW(1, 'A', 'u1'), ROW(2, 'B', 'u2a'))::ARRAY<
ROW<col1 BIGINT, col2 VARCHAR, col3 VARCHAR>
>,
base2 := ARRAY(ROW(2, 'BB', 'u2b'), ROW(2, 'BB', 'u2c'), ROW(3, 'CC', 'u3'))::ARRAY<
ROW<col1 BIGINT, col2 VARCHAR, col3 VARCHAR>
>
RETURN
no_dedup := ARRAY_CONCAT(base1, base2),
dedup_on_col1 := ARRAY_CONCAT(base1, base2 DEDUPLICATE ON col1),
dedup_on_col1_and_col2 := ARRAY_CONCAT(
base1,
base2 DEDUPLICATE ON col1,
col2
)
;| NO_DEDUP ARRAY<ROW<col1 BIGINT, col2 VARCHAR, col3 VARCHAR>> | DEDUP_ON_COL1 ARRAY<ROW<col1 BIGINT, col2 VARCHAR, col3 VARCHAR>> | DEDUP_ON_COL1_AND_COL2 ARRAY<ROW<col1 BIGINT, col2 VARCHAR, col3 VARCHAR>> |
|---|---|---|
| [{col1: 1, col2: A, col3: u1}, {col1: 2, col2: B, col3: u2a}, {col1: 2, col2: BB, col3: u2b}, {col1: 2, col2: BB, col3: u2c}, {col1: 3, col2: CC, col3: u3}] | [{col1: 1, col2: A, col3: u1}, {col1: 2, col2: BB, col3: u2c}, {col1: 3, col2: CC, col3: u3}] | [{col1: 1, col2: A, col3: u1}, {col1: 2, col2: B, col3: u2a}, {col1: 2, col2: BB, col3: u2c}, {col1: 3, col2: CC, col3: u3}] |
Zipping arrays into rows
ZIP takes one or more plain arrays and combines them into an array of rows, pairing elements by position. If the arrays have different lengths, shorter ones are padded with NULL.
RETURN
zip_one := ZIP(ARRAY(1, 2, 3) AS my_field),
zip_many := ZIP(ARRAY(1, 2, 3) AS my_field1, ARRAY('a', 'b') AS my_field2)
;| ZIP_ONE ARRAY<ROW<my_field BIGINT>> | ZIP_MANY ARRAY<ROW<my_field1 BIGINT, my_field2 VARCHAR>> |
|---|---|
| [{my_field: 1}, {my_field: 2}, {my_field: 3}] | [{my_field1: 1, my_field2: a}, {my_field1: 2, my_field2: b}, {my_field1: 3, my_field2: NULL}] |
Merging arrays of rows by position
ARRAY_MERGE without a join clause combines two arrays of rows by position: the first row of each array is merged together, the second with the second, and so on. If the arrays have different lengths, shorter ones are padded with NULL.
RETURN
array1 := ARRAY(ROW(1, 'A'), ROW(2, 'B'))::ARRAY<
ROW<col1 BIGINT, col2 VARCHAR>
>,
array2 := ARRAY(ROW(3, 'C'), ROW(4, 'D'))::ARRAY<
ROW<col3 BIGINT, col4 VARCHAR>
>,
merged := ARRAY_MERGE(array1, array2)
;| ARRAY1 ARRAY<ROW<col1 BIGINT, col2 VARCHAR>> | ARRAY2 ARRAY<ROW<col3 BIGINT, col4 VARCHAR>> | MERGED ARRAY<ROW<col1 BIGINT, col2 VARCHAR, col3 BIGINT, col4 VARCHAR>> |
|---|---|---|
| [{col1: 1, col2: A}, {col1: 2, col2: B}] | [{col3: 3, col4: C}, {col3: 4, col4: D}] | [{col1: 1, col2: A, col3: 3, col4: C}, {col1: 2, col2: B, col3: 4, col4: D}] |
Merging arrays of rows by key
When two arrays share a field name, ARRAY_MERGE(... VIA field) matches equal values. The first array is the left side: its rows remain when no match exists, with NULL in fields from the other array.
DEFINE
key_id := INPUT(BIGINT),
array1 := ARRAY(ROW(key_id AS field_1, 'A'), ROW(2, 'B'))::ARRAY<
ROW<num BIGINT, letter VARCHAR>
>,
array2 := ARRAY(ROW(1, TRUE), ROW(2, FALSE))::ARRAY<
ROW<num BIGINT, num_is_odd BOOLEAN>
>
RETURN
key_id,
array1,
array2,
joined_merge := ARRAY_MERGE(
array1,
array2
VIA num
)
FOR
key_id := BIND_VALUES(ARRAY(1, 2, 3))
;| KEY_ID BIGINT | ARRAY1 ARRAY<ROW<num BIGINT, letter VARCHAR>> | ARRAY2 ARRAY<ROW<num BIGINT, num_is_odd BOOLEAN>> | JOINED_MERGE ARRAY<ROW<num BIGINT, letter VARCHAR, num_is_odd BOOLEAN>> |
|---|---|---|---|
| 1 | [{num: 1, letter: A}, {num: 2, letter: B}] | [{num: 1, num_is_odd: true}, {num: 2, num_is_odd: false}] | [{num: 1, letter: A, num_is_odd: true}, {num: 2, letter: B, num_is_odd: false}] |
| 2 | [{num: 2, letter: A}, {num: 2, letter: B}] | [{num: 1, num_is_odd: true}, {num: 2, num_is_odd: false}] | [{num: 2, letter: A, num_is_odd: false}, {num: 2, letter: B, num_is_odd: false}] |
| 3 | [{num: 3, letter: A}, {num: 2, letter: B}] | [{num: 1, num_is_odd: true}, {num: 2, num_is_odd: false}] | [{num: 3, letter: A, num_is_odd: NULL}, {num: 2, letter: B, num_is_odd: false}] |
If the right array has duplicate keys, every matching pair is emitted. This fan-out is useful when you intend to reshape or aggregate the result, but it is not an implicit “pick one” operation.
Merging arrays of rows by predicate
Use ARRAY_MERGE(... ON predicate) when the arrays have different key names or need a range, as-of, distance, or other non-equi condition. Field names in the predicate identify the two array sides directly.
DEFINE
products := ZIP(
ARRAY(1::BIGINT, 2::BIGINT, 3::BIGINT) AS product_id,
ARRAY(52.20, 302.00, 1200.05) AS price
),
bands := ZIP(
ARRAY(0., 100., 1000.) AS price_min,
ARRAY(100., 1000., 100000.) AS price_max,
ARRAY('low', 'mid', 'high') AS band
),
merged := ARRAY_MERGE(
products,
bands
ON PRICE_MIN <= PRICE AND PRICE < PRICE_MAX
)
RETURN
merged
;| MERGED ARRAY<ROW<product_id BIGINT, price DECIMAL(6,2), price_min DECIMAL(4,0), price_max DECIMAL(6,0), band VARCHAR>> |
|---|
| [{product_id: 1, price: 52.20, price_min: 0, price_max: 100, band: low}, {product_id: 2, price: 302.00, price_min: 100, price_max: 1000, band: mid}, {product_id: 3, price: 1200.05, price_min: 1000, price_max: 100000, band: high}] |
ON has the same left-array and fan-out semantics as VIA: unmatched left rows remain, and every matching right row produces an output row. To choose one match, follow the merge with TRANSFORM() and an aggregate such as MAX_BY().
Carrying scalars into rows
CARRY broadcasts one or more scalar values into every row of an array, adding them as new fields. This is useful when entity-level attributes (a customer's country, a total revenue figure) need to be available alongside each row for downstream computation inside a TRANSFORM.
RETURN
orders := ARRAY(ROW(101, 49.99), ROW(102, 129.00), ROW(103, 15.50))::ARRAY<
ROW<order_id BIGINT, price DECIMAL(10,2)>
>,
customer_revenue := 500.00,
country := 'ES',
enriched := CARRY(customer_revenue, country INTO orders)
;| ORDERS ARRAY<ROW<order_id BIGINT, price DECIMAL(10,2)>> | CUSTOMER_REVENUE DECIMAL(5,2) | COUNTRY VARCHAR | ENRICHED ARRAY<ROW<order_id BIGINT, price DECIMAL(10,2), customer_revenue DECIMAL(5,2), country VARCHAR>> |
|---|---|---|---|
| [{order_id: 101, price: 49.99}, {order_id: 102, price: 129.00}, {order_id: 103, price: 15.50}] | 500.00 | ES | [{order_id: 101, price: 49.99, customer_revenue: 500.00, country: ES}, {order_id: 102, price: 129.00, customer_revenue: 500.00, country: ES}, {order_id: 103, price: 15.50, customer_revenue: 500.00, country: ES}] |
Each scalar becomes a new field. The field name defaults to the feature name, or you can rename it with AS. This is equivalent to the longer ARRAY_MERGE(ZIP(REPEAT(scalar, ARRAY_COUNT(array)) AS name), array) pattern, but more concise and efficient.
Structural operations (dedicated pages)
The main structural operations on arrays of rows each have their own page:
Extend enriches each row by joining on an external feature. Use it when you need to add data from another entity (e.g., adding product names to an array of order rows).
Transform applies a full subquery to the rows — map, filter, aggregate, sort, or limit. Think of it as running a mini FeatureQL query inside each entity.
Lookup with indexes builds an
INDEXfrom an array of rows for repeated key lookups. UseINDEX_UNIQUE()when each key maps to one row, orINDEX_MULTI()when keys can match many rows — thenELEMENT_AT_KEY()/ELEMENTS_AT_KEY()instead of scanning withTRANSFORM(... WHERE ...).Recurse starts from a seed
ROWand produces an array of rows — one per recursive step. Use it for running totals and sequences (iterative mode), or for walking trees and graphs withVIA … BIND TO … FOLLOW …(traversal mode). The result composes withTRANSFORM()like any other array of rows.