Composability and purity
A persisted feature is a named function. FeatureQL keeps that function the same when you reuse it, select extra siblings, or hop across entities — so composition does not silently change the answer.
A feature is a function of its inputs
Purity is the starting point: the same inputs always produce the same value. There is no hidden session state, and evaluation order does not change the result. Design principles explains why that property makes reuse, VARIANT(), and static checks possible.
Two inputs are easy to forget when the surface looks like SQL:
- Grain — how rows collapse.
SUM(amount) GROUP BY regionis a function ofregion, not of whatever else happens to appear inRETURN. - Population — which rows exist.
FORis the evaluation universe. Changing who is bound can change a total even when the formula is unchanged.
Persistence stores the function, not one query's result. A shared feature stays safe to compose only when those two inputs stay part of its contract.
Definition grain stays with the formula
In FeatureQL, GROUP BY lives inside the aggregate (GROUP BY (region) — parentheses required). That grouping is the feature's definition grain. A RETURN that writes GROUP BY must project those keys. Selecting another feature beside it is presentation: it must be determined by those same keys. The same rule applies when a second aggregate consumes the first: AVG(CITY_REVENUE) GROUP BY COUNTRY averages one row per city, the grain CITY_REVENUE was defined at (see Metric additivity ).
This query groups sales by region. REGION_LABEL is UPPER(REGION), so it is determined by the grouping key. North still totals 300, even though the source has two stores:
DEFINE
k := INPUT(BIGINT),
src := INLINE_COLUMNS(
k BIGINT BIND TO k,
region VARCHAR,
store VARCHAR,
amount BIGINT
FROM JSON([
{"k": 1, "region": "north", "store": "s1", "amount": 100},
{"k": 2, "region": "north", "store": "s2", "amount": 200},
{"k": 3, "region": "south", "store": "s3", "amount": 50}
])
),
region := src[region],
region_label := UPPER(region),
total := SUM(src[amount]) GROUP BY (region)
RETURN
region,
region_label,
total
FOR
k := BIND_VALUES(ARRAY(1, 2, 3))
ORDER BY region
;| REGION VARCHAR | REGION_LABEL VARCHAR | TOTAL BIGINT |
|---|---|---|
| north | NORTH | 300 |
| south | SOUTH | 50 |
Selecting REGION_LABEL did not change TOTAL. That is the composability rule: extra presentation must not redefine the aggregate.
An independent sibling is rejected. STORE is not determined by REGION, so this does not compile:
RETURN REGION, STORE, TOTAL := SUM(SRC[amount]) GROUP BY REGION
ERROR UE/STATIC-ANALYSIS-RETURN-FINER-THAN-GROUP-BY FeatureQL does not add STORE to the emitted GROUP BY to make the query run. That silent widening is what would make the same formula mean 300 in one query and 100 / 200 in another.
A column that merely happens to be constant in a fixture is also not a proof. A source-mapped region_label sitting beside SUM(...) GROUP BY REGION is rejected until the model proves the dependency, or you include the attribute in an explicit GROUP BY.
| Intent | Write |
|---|---|
| Keep the declared grain | Select only keys and features determined by those keys |
| Aggregate at a finer grain | SUM(amount) GROUP BY (region, store) |
| Repeat a coarser total on finer rows | SUM(amount) OVER (PARTITION BY region) |
UNSAFE on a metric does not opt into widening. Grain is repaired by naming the group or by broadcasting with OVER (...). See Grouping & Unnesting and Window functions .
Population is an input
FOR chooses the rows the graph may see. That is not a WHERE filter after the fact; it is the universe the function is evaluated over.
A grouped aggregate is row-local: each output is determined by its grouping keys. SUM(amount) GROUP BY region for north is a function of the north rows. Adding or removing a south key in FOR does not change north. Adding another north store does change north — that is a change of input inside the group, not a hidden ambient total.
An ungrouped SUM(amount) is ambient. It collapses whoever happens to be bound. That is useful while exploring, and the value is still deterministic for that query. It is not a portable function: bind one key and you get 100; bind three keys and you get 350, with no grouping key in the formula to explain the difference.
Name the universe when the feature must be reused. KEYSET() records how to enumerate an entity; @BIND_KEYSET() pins that published population in FOR. Changing the keyset is an explicit input change, the same way changing a CONST is an explicit scalar change. The two are not interchangeable: a constant parameterizes evaluation; a keyset selects rows. See Bind keyset .
Windows that collapse the ambient universe cannot be persisted (UE/PERSIST-AMBIENT-POPULATION). Treat other ungrouped collapse as query-local unless you attach a named KEYSET(). Hybrid SQL (/* SQL */ blocks) cannot be stored as features — only pure FeatureQL is the system of record. A persisted view still authors FeatureQL and stores compiled SQL for clients. See Persist and reuse features and SQL interoperability .
RELATED() stays a function of the parent key
RELATED() looks up or aggregates another entity and returns one value per outer row. A grouped child aggregate is still row-local when it groups by the join key that identifies the parent.
This example binds two customers and their orders. Each lifetime is a function of that customer:
DEFINE
customers := ENTITY(),
orders := ENTITY(),
customer_id := INPUT(BIGINT#customers),
order_id := INPUT(BIGINT#orders),
order_facts := INLINE_COLUMNS(
order_id BIGINT#orders BIND TO order_id,
customer_id BIGINT#customers,
amount BIGINT
FROM JSON([
{"order_id": 101, "customer_id": 1, "amount": 10},
{"order_id": 102, "customer_id": 1, "amount": 20},
{"order_id": 103, "customer_id": 2, "amount": 40}
])
),
order_amount := order_facts[amount],
order_customer := order_facts[customer_id],
lifetime := RELATED(
SUM(order_amount) GROUP BY (order_customer) VIA customer_id
)
RETURN
customer_id,
lifetime
FOR
customer_id := BIND_VALUES(ARRAY(1, 2)),
NESTED order_id := BIND_VALUES(ARRAY(101, 102, 103))
ORDER BY customer_id
;| CUSTOMER_ID BIGINT#CUSTOMERS | LIFETIME BIGINT |
|---|---|
| 1 | 30 |
| 2 | 40 |
Customer 1 is 30 whether or not you also select a label derived from CUSTOMER_ID. Extra display columns on the parent must not fan the child scan out into a cross product. Bind the child input as NESTED so the inner aggregation can see those rows; do not rely on unused sibling keys in a composite FOR to change the measure.
RELATED() syntax and VIA / ON forms: Cross-entity joins .
Persist only portable functions
CREATE FEATURES publishes the graph. Later queries bind inputs and compose. The stored formula must still mean the same thing:
- Row-local — every population-sensitive step is bounded by keys that determine its output (
GROUP BYthose keys, orRELATED()grouped on the join key). - Named population — an otherwise ambient collapse names a direct
KEYSET()so the row universe is part of the feature's inputs.
Query-local exploration can stay ambient. Shared KPIs should not. Reader-resolved CONST values remain scalar inputs; freeze a compile-time value with @eval_as_literal() when the graph must not depend on when it was compiled. See Create features and Metaprogramming .
EXPLAIN (FIELDS (FEATURES)) is the inspection surface for inferred contracts, including how a feature collapses rows. See EXPLAIN .
Other checks that keep composition safe
Grain and population sit beside the five semantic properties on Semantic modeling . None of them replaces another:
| Check | What it keeps stable |
|---|---|
| Datatypes and explicit casts | The same expression across backends — Types |
Entity # | Which business object a key identifies — Entities |
Units @ | What a number measures — Units and measures |
PER | How often a repeated value contributes — Aggregation multiplicity |
| Additivity | Whether a summarized value may be combined again — Metric additivity |
Fixed GROUP BY grain | The formula's collapse keys, independent of SELECT |
Population / KEYSET() | The row universe the formula is evaluated over |
UNSAFE acknowledges non-additive metric algebra. It does not name a population, prove a determinant, or allow RETURN-driven widening.
What FeatureQL will not prove
These are authoring contracts, not warehouse scans:
DETERMINED BYandBIND TOuniqueness are trusted declarations. A false determinant plusPER keystill contributes each distinct(key, value)pair. FeatureQL does not inspect source rows for uniqueness. The warehouse check for each assumption is Data contracts .- Fixture-level constancy is not a proof. A label that is unique per region in today's sample is not determined until the model says so.
- Business meaning still needs review. The compiler rejects specific wrong-result patterns; it does not prove that a valid query is the KPI you intended.
Metric additivity lists further V1 limits (hybrid SQL outside FeatureQL, JSON round-trips, one diagnostic at a time).
Choose the intended contract
| Situation | Write |
|---|---|
| Reuse a grouped total beside a derived label | Keep GROUP BY region; derive the label from region |
| Need store rows and store totals | GROUP BY (region, store) |
| Need store rows and the region total on each | OVER (PARTITION BY region) |
| Explore an ungrouped total in one query | Query-local SUM(amount) is fine; do not persist it as a shared KPI |
| Share a population-wide total | Persist a KEYSET() and bind it with @BIND_KEYSET() |
| Share a per-entity metric from child rows | RELATED(SUM(...) GROUP BY foreign_key VIA parent_key) |
Next
- Semantic modeling — see grain and population beside entities, units, multiplicity, and additivity
- Persist and reuse features — publish a function, not a one-off query
- Bind keyset — name the row universe
- Grouping & Unnesting — change result granularity on purpose
- Design principles — why purity is a language rule