Purity and determinism

Every feature is a deterministic function of its inputs: same inputs, same source data, same backend, same result. A feature has no side effects. It cannot write, log, call out or read the clock.

Where non-determinism can enter

There are two entry points. The compiler refuses NOW(), RANDOM() and similar functions anywhere else.

FeatureQL
DEFINE
    a := INPUT(BIGINT),
    jitter := a + RANDOM()
RETURN
    jitter
FOR
    a := BIND_VALUE(1)
;
Result
ERROR UE/FUNCTION-NOT-SUPPORTED RANDOM() is only supported in constant-evaluation contexts when defining unnamed feature defined with RANDOM(). Use @eval_as_text() or @eval_as_literal() where appropriate.

Compile time. CONST definitions, @eval_as_literal() and @eval_as_text() run before the graph is built and inject frozen values. @eval_as_literal(CURRENT_TIMESTAMP()) is the time of compilation, identical on every row. The @ marks the stage in the source. See Metaprogramming .

Declared sources. EXTERNAL_COLUMNS, EXTERNAL_REDIS, EXTERNAL_HTTP and the JDBC VIEW declare where outside data enters, with types given at the boundary. Their results are inputs to the graph. See Mapping data sources .

Randomness that repeats

HASH01(key) gives a stable value in [0, 1) per key. SAMPLE_UNIFORM, SAMPLE_GAUSSIAN, SAMPLE_VALUE and the other SAMPLE_* functions build on hashes.

FeatureQL
DEFINE
    customer_id := INPUT(BIGINT),
    bucket := HASH01(customer_id::VARCHAR),
    in_test := bucket <.3 0.77e0
RETURN
    customer_id,
    in_test
FOR
    customer_id := BIND_VALUES(ARRAY(1, 2, 3, 4))
ORDER BY customer_id
;
Result
CUSTOMER_ID BIGINTIN_TEST BOOLEAN
1TRUE
2FALSE
3FALSE
4TRUE

An experiment assignment computed in batch and the same assignment computed at request time are equal, because both are a function of the key. Nothing is stored or reconciled.

Ties

Some functions pick one value among several rows. When the ordering does not determine which row wins, the result would depend on the engine. The compiler reports this as a warning (SELECTION-ARBITRARY).

FeatureQL
DEFINE
    customers := ENTITY(),
    orders := ENTITY(),
    customer_id := INPUT(BIGINT#customers),
    order_id := INPUT(BIGINT#orders),
    ord := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        customer_id BIGINT#customers,
        day DATE,
        channel VARCHAR
        FROM JSON([
            {"order_id": 101, "customer_id": 1, "day": "2026-01-02", "channel": "web"},
            {"order_id": 102, "customer_id": 1, "day": "2026-01-02", "channel": "store"}
        ])
    ),
    last_channel := customer_id.RELATED(
        MAX_BY(ord[channel], ord[day]) GROUP BY (ord[customer_id])
    ),
    some_channel := customer_id.RELATED(
        ANY_VALUE(ord[channel]) GROUP BY (ord[customer_id])
    ),
    channels := customer_id.RELATED(
        ARRAY_AGG(ord[channel]) GROUP BY (ord[customer_id])
    )
RETURN
    customer_id,
    last_channel,
    some_channel,
    channels
FOR
    customer_id := BIND_VALUE(1),
    NESTED order_id := BIND_VALUES(ARRAY(101, 102))
;
Result
CUSTOMER_ID BIGINT#CUSTOMERSLAST_CHANNEL VARCHARSOME_CHANNEL VARCHARCHANNELS ARRAY<VARCHAR>
1webweb[store, web]

Both orders have the same day, so MAX_BY(channel, day) has two candidates. The query runs, and the warning asks for an ordering that determines the selected row.

Two other cases appear in the same block:

  • ANY_VALUE asks for an arbitrary value by name, so it does not warn. APPROX_DISTINCT and APPROX_PERCENTILE state an approximate result in the same way.
  • ARRAY_AGG over rows has several possible element orders. The result above is in value order. An ORDER BY inside ARRAY_AGG sets the order explicitly.

No control flow

Nothing at run time decides which features exist. A CASE selects a value, and the features it references are in the graph either way. FeatureQL fixes the value. Whether the engine evaluates a branch that was not selected is decided by the engine.

Closed features

A feature is closed when no EXTERNAL_* source is reachable in its dependency graph. A closed feature can be re-evaluated anywhere, which is why TRANSFORM ... IMPORT accepts only closed definitions. RELATED, EXTEND and INPUT do not affect this. Only a link to physical data does.

What purity allows

Validation without execution. validate() returns the formatted query, the inferred output types and a backend preflight without scanning data. The registry checks every change the same way.

Safe substitution. Replacing a dependency cannot change behavior through a hidden channel. See Composition and substitution .

Laziness. An unevaluated feature is unobservable, so skipping it cannot change a result. A namespace can hold a broad library of features, and a query pays only for the ones it reaches.

Identical reruns. This is what gives a regression test on a business number its meaning.

Properties readable from the dependency graph. They can be checked without running the feature:

If the dependency graph contains nothen the feature
INPUT()is a constant
EXTERNAL_* sourceis closed
UNSAFE_CAST or UNSAFE aggregateuses no escape from the semantic contracts

Limits

Purity is a property of the computation. It says nothing about whether the source data is fresh or the source declarations are true. See the overview .