E-commerce
This tutorial builds a small retail feature model and uses it to answer questions that cross customers, orders, and line items.
The business questions are simple—How many orders did a customer place? Where was their latest order? What was it worth?—but each follows a different relationship shape. Learning those shapes here will make the rest of the analytics tutorials easier to read.
No previous domain tutorial is required. If ENTITY(), mappings, or FOR are completely new, the shorter FeatureQL homepage companion is a useful warm-up.
Run Data and Model before the relationship examples. If a notebook restarts, rerun those sections before continuing.
Meet the retail data
We have three customers, four orders, and seven line items. John has two orders, while Jane and Jack have one each.
The setup also includes two small pre-aggregated helpers: one stores each customer's order IDs and a designated last_order_id, and another stores each order's total price. They keep the later array example focused. In production, you could derive or materialize the same values upstream.
SELECT customer_id, name
FROM tutorial_ecomm.customers
ORDER BY customer_id;| CUSTOMER_ID BIGINT | NAME VARCHAR |
|---|---|
| 100 | John Doe |
| 101 | Jane Doe |
| 102 | Jack Doe |
After running the cell, inspect customer 100: that is John, whose orders 200 and 203 give us a useful one-to-many case. The helper stores order 200 as John's last_order_id; this tutorial follows that stored field rather than deriving the latest order from timestamps.
Give the tables business meaning
First declare customers, orders, and items as distinct entities. Their keys are all integers in storage, but the entity annotations stop a customer ID from being mistaken for an order ID.
CREATE OR REPLACE FEATURES IN FM.ECOMM AS
PERSIST
customers := ENTITY(),
orders := ENTITY(),
items := ENTITY(),
customer_id := INPUT(BIGINT#customers),
order_id := INPUT(BIGINT#orders),
item_id := INPUT(BIGINT#items)
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.ECOMM.CUSTOMERS | CREATED | Feature created as not exists |
| FM.ECOMM.ORDERS | CREATED | Feature created as not exists |
| FM.ECOMM.ITEMS | CREATED | Feature created as not exists |
| FM.ECOMM.CUSTOMER_ID | CREATED | Feature created as not exists |
| FM.ECOMM.ORDER_ID | CREATED | Feature created as not exists |
| FM.ECOMM.ITEM_ID | CREATED | Feature created as not exists |
Now map the physical columns. EXTERNAL_COLUMNS() records the source table, column types, and key used to bind each row.
CREATE OR REPLACE FEATURES IN FM.ECOMM AS
PERSIST
tables.customers := EXTERNAL_COLUMNS(
customer_id BIGINT#customers BIND TO customer_id,
name VARCHAR,
time_create TIMESTAMP
FROM TABLE(tutorial_ecomm.customers)
),
tables.orders := EXTERNAL_COLUMNS(
order_id BIGINT#orders BIND TO order_id,
order_customer_id BIGINT#customers,
order_city_name VARCHAR,
time_create TIMESTAMP
FROM TABLE(tutorial_ecomm.orders)
),
tables.items := EXTERNAL_COLUMNS(
item_id BIGINT#items BIND TO item_id,
item_order_id BIGINT#orders,
item_product_name VARCHAR,
price DECIMAL(10,2),
quantity BIGINT
FROM TABLE(tutorial_ecomm.items)
),
tables.customer_orders := EXTERNAL_COLUMNS(
customer_id BIGINT#customers BIND TO customer_id,
last_order_id BIGINT#orders,
orders ARRAY(BIGINT#orders)
FROM TABLE(tutorial_ecomm.customer_orders)
),
tables.order_totals := EXTERNAL_COLUMNS(
order_id BIGINT#orders BIND TO order_id,
price DECIMAL(10,2)
FROM TABLE(tutorial_ecomm.order_totals)
)
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.ECOMM.TABLES.CUSTOMERS | CREATED | Feature created as not exists |
| FM.ECOMM.TABLES.ORDERS | CREATED | Feature created as not exists |
| FM.ECOMM.TABLES.ITEMS | CREATED | Feature created as not exists |
| FM.ECOMM.TABLES.CUSTOMER_ORDERS | CREATED | Feature created as not exists |
| FM.ECOMM.TABLES.ORDER_TOTALS | CREATED | Feature created as not exists |
The persisted TABLES.* features represent raw sources. The shorter names—such as customer_name, order_city, and item_price—are the stable business features the queries will compose.
See what a binding selects
Before crossing relationships, look at each population separately. Binding customer IDs produces one row per customer:
RETURN
customer_id,
name := tables.customers[name],
time_create := tables.customers[time_create]
FROM FM.ECOMM
FOR
customer_id := BIND_COLUMNS(
customer_id
FROM TABLE(tutorial_ecomm.customers)
)
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | NAME VARCHAR | TIME_CREATE TIMESTAMP(3) |
|---|---|---|
| 100 | John Doe | 2025-09-18T14:30:00 |
| 101 | Jane Doe | 2024-12-08T09:45:00 |
| 102 | Jack Doe | 2025-02-03T12:00:02 |
Binding order IDs changes the output grain to one row per order:
RETURN
order_id,
order_customer_id := tables.orders[order_customer_id],
city := tables.orders[order_city_name],
time_create := tables.orders[time_create]
FROM FM.ECOMM
FOR
order_id := BIND_COLUMNS(
order_id
FROM TABLE(tutorial_ecomm.orders)
)
ORDER BY order_id
;| ORDER_ID BIGINT#FM.ECOMM.ORDERS | ORDER_CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | CITY VARCHAR | TIME_CREATE TIMESTAMP(3) |
|---|---|---|---|
| 200 | 100 | Barcelona | 2025-09-18T15:00:00 |
| 201 | 101 | Barcelona | 2024-12-08T09:45:00 |
| 202 | 102 | Barcelona | 2025-02-03T12:00:02 |
| 203 | 100 | Madrid | 2025-09-18T14:30:00 |
FOR therefore does more than supply values: it tells you which business objects the query is evaluating. Later, NESTED bindings will make child keys available inside a relationship without multiplying these outer rows.
Align features on the same entity
Start at the customer grain and select two features already keyed by CUSTOMER_ID. Because they describe the same entity, FeatureQL aligns them directly; no RELATED() is needed.
RETURN
customer_id,
customer_name := tables.customers[name],
customer_orders := tables.customer_orders[orders]
FROM FM.ECOMM
FOR
customer_id := BIND_VALUES(ARRAY(100, 101, 102))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | CUSTOMER_NAME VARCHAR | CUSTOMER_ORDERS ARRAY<BIGINT#FM.ECOMM.ORDERS> |
|---|---|---|
| 100 | John Doe | [200, 203] |
| 101 | Jane Doe | [201] |
| 102 | Jack Doe | [202] |
John's row contains the order array [200, 203]. Jane and Jack each have a one-element array.
Aggregate child rows back to a customer
Now count orders for each customer. The result starts at the customer key, groups the child rows by their customer foreign key, and returns the count to the matching customer.
RETURN
customer_id,
num_orders := RELATED(
SUM(1) GROUP BY (tables.orders[order_customer_id]) VIA customer_id
)
FROM FM.ECOMM
FOR
customer_id := BIND_VALUES(ARRAY(100, 101, 102)),
NESTED order_id := BIND_VALUES(ARRAY(200, 201, 202, 203))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | NUM_ORDERS BIGINT |
|---|---|
| 100 | 2 |
| 101 | 1 |
| 102 | 1 |
The result is John 2, Jane 1, and Jack 1. In SQL, this shape usually requires a GROUP BY subquery followed by a join.
Follow a foreign key to one parent row
Each customer row stores a last_order_id. Follow that key to the order entity and retrieve its city:
DEFINE
last_order_city := RELATED(
tables.orders[order_city_name] VIA tables.customer_orders[last_order_id]
)
RETURN
customer_id,
last_order_city
FROM FM.ECOMM
FOR
customer_id := BIND_VALUES(ARRAY(100, 101, 102))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | LAST_ORDER_CITY VARCHAR |
|---|---|
| 100 | Barcelona |
| 101 | Barcelona |
| 102 | Barcelona |
All three designated last orders are in Barcelona. This is a lookup rather than an aggregation: one order ID identifies one order row.
Continue across another relationship
The next question travels farther. Begin with each customer's stored last_order_id, find the items that belong to that order, then sum price × quantity.
DEFINE
last_order_id := tables.customer_orders[last_order_id],
last_order_price := RELATED(
SUM(tables.items[price] * tables.items[quantity]::DECIMAL)
GROUP BY (tables.items[item_order_id]) VIA last_order_id
)
RETURN
customer_id,
last_order_id,
last_order_price
FROM FM.ECOMM
FOR
customer_id := BIND_VALUES(ARRAY(100, 101, 102)),
NESTED item_id := BIND_VALUES(ARRAY(300, 301, 302, 303, 304, 305, 306))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | LAST_ORDER_ID BIGINT#FM.ECOMM.ORDERS | LAST_ORDER_PRICE DECIMAL(30,2) |
|---|---|---|
| 100 | 200 | 31.15 |
| 101 | 201 | 36.15 |
| 102 | 202 | 55.20 |
John's stored last order 200 totals 31.15. Jane's totals 36.15, and Jack's totals 55.20. The output remains one row per customer even though the calculation reaches line items.
Enrich arrays with EXTEND()
So far, RELATED() has returned one scalar per customer. To keep several related rows, start with the customer's order-ID array:
ZIP()turns the scalar IDs into rows.EXTEND()follows each order ID to its total price.- The enriched array stays inside the customer row.
RETURN
customer_id,
customer_orders := tables.customer_orders[orders],
customer_orders_details := EXTEND(
ZIP(customer_orders AS order_id)
WITH tables.order_totals[price] AS order_price
)
FROM FM.ECOMM
FOR
customer_id := BIND_VALUES(ARRAY(100, 101, 102))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | CUSTOMER_ORDERS ARRAY<BIGINT#FM.ECOMM.ORDERS> | CUSTOMER_ORDERS_DETAILS ARRAY<ROW<order_id BIGINT#FM.ECOMM.ORDERS, order_price DECIMAL(10,2)>> |
|---|---|---|
| 100 | [200, 203] | [{order_id: 200, order_price: 31.15}, {order_id: 203, order_price: 16.05}] |
| 101 | [201] | [{order_id: 201, order_price: 36.15}] |
| 102 | [202] | [{order_id: 202, order_price: 55.20}] |
John now carries two enriched rows: order 200 at 31.15 and order 203 at 16.05.
Compose the relationship shapes
The final query combines customer name, enriched orders, stored last-order city, order count, and stored last-order value. Read each selected feature as a separate business question that FeatureQL aligns at the customer grain.
DEFINE
customer_name := tables.customers[name],
customer_orders := tables.customer_orders[orders],
last_order_id := tables.customer_orders[last_order_id],
last_order_city := last_order_id.RELATED(tables.orders[order_city_name]),
num_orders := ARRAY_COUNT(customer_orders),
last_order_price := last_order_id.RELATED(
SUM(tables.items[price] * tables.items[quantity]::DECIMAL)
GROUP BY (tables.items[item_order_id])
)
RETURN
customer_id,
customer_name,
customer_orders,
last_order_id,
last_order_city,
num_orders,
last_order_price
FROM FM.ECOMM
FOR
customer_id := BIND_VALUES(ARRAY(100, 101, 102)),
NESTED item_id := BIND_VALUES(ARRAY(300, 301, 302, 303, 304, 305, 306))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#FM.ECOMM.CUSTOMERS | CUSTOMER_NAME VARCHAR | CUSTOMER_ORDERS ARRAY<BIGINT#FM.ECOMM.ORDERS> | LAST_ORDER_ID BIGINT#FM.ECOMM.ORDERS | LAST_ORDER_CITY VARCHAR | NUM_ORDERS BIGINT | LAST_ORDER_PRICE DECIMAL(30,2) |
|---|---|---|---|---|---|---|
| 100 | John Doe | [200, 203] | 200 | Barcelona | 2 | 31.15 |
| 101 | Jane Doe | [201] | 201 | Barcelona | 1 | 36.15 |
| 102 | Jack Doe | [202] | 202 | Barcelona | 1 | 55.20 |
Notice the bindings: customers define the outer rows; orders and items are NESTED because the relationship calculations need their keys internally.
What's next
You have seen automatic same-entity alignment, child aggregation, parent lookup, a multi-hop calculation, and array enrichment.
Continue with SaaS metrics to reuse those relationships in point-in-time measures and a persisted health score. Use the RELATED() and EXTEND() reference pages when you need the complete syntax.