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 region is a function of region, not of whatever else happens to appear in RETURN.
  • Population — which rows exist. FOR is 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:

FeatureQL
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
;
Result
REGION VARCHARREGION_LABEL VARCHARTOTAL BIGINT
northNORTH300
southSOUTH50

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
null

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.

IntentWrite
Keep the declared grainSelect only keys and features determined by those keys
Aggregate at a finer grainSUM(amount) GROUP BY (region, store)
Repeat a coarser total on finer rowsSUM(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:

FeatureQL
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
;
Result
CUSTOMER_ID BIGINT#CUSTOMERSLIFETIME BIGINT
130
240

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 BY those keys, or RELATED() 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:

CheckWhat it keeps stable
Datatypes and explicit castsThe same expression across backends — Types
Entity #Which business object a key identifies — Entities
Units @What a number measures — Units and measures
PERHow often a repeated value contributes — Aggregation multiplicity
AdditivityWhether a summarized value may be combined again — Metric additivity
Fixed GROUP BY grainThe 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 BY and BIND TO uniqueness are trusted declarations. A false determinant plus PER key still 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

SituationWrite
Reuse a grouped total beside a derived labelKeep GROUP BY region; derive the label from region
Need store rows and store totalsGROUP BY (region, store)
Need store rows and the region total on eachOVER (PARTITION BY region)
Explore an ungrouped total in one queryQuery-local SUM(amount) is fine; do not persist it as a shared KPI
Share a population-wide totalPersist a KEYSET() and bind it with @BIND_KEYSET()
Share a per-entity metric from child rowsRELATED(SUM(...) GROUP BY foreign_key VIA parent_key)

Next