Experimentation functions

Running experiments and rolling out features requires one guarantee: the same user must always land in the same bucket. FeatureQL provides two functions — HASH01() for deterministic assignment and GRADUAL_ROLLOUT() for time-based deployment — that work consistently across distributed systems without coordination.

Deterministic bucketing with HASH01()

HASH01() takes a string key and returns a deterministic value between 0.0 and 1.0. The same key always produces the same output, so a user's experiment assignment never changes between queries or systems.

HASH01(key: VARCHAR) -> DOUBLE
sql

The key is typically an entity ID concatenated with a salt — the salt ensures that a user's assignment in one experiment is independent of their assignment in another. Without a salt, the same users would always end up in the same percentile across all experiments.

This example shows a complete experiment setup: a long-term holdout group (5%), a feature toggle based on business rules, and a statistical exposure group (10% of eligible users). A user is only exposed to the experiment if they pass all three gates:

FeatureQL
WITH
    USER_ID := INPUT(BIGINT),
    USER_ID := BIND_VALUES(SEQUENCE(35,40)),
    USER_COUNTRY := 'ES',
    IS_NEW_CUSTOMER := FALSE,
    SALT_LT_HOLDOUT := 'SALT_LTHO',
    SALT_EXPERIMENT := 'SALT_EXP1'
SELECT
    USER_ID,
    IN_LT_HOLDOUT := HASH01((USER_ID::VARCHAR) || SALT_LT_HOLDOUT) <.5 0.05e0,
    IN_FEATURE_TOGGLE := USER_COUNTRY IN ('ES', 'IT') AND NOT IS_NEW_CUSTOMER,
    IN_STAT_EXPOSURE := HASH01((USER_ID::VARCHAR) || SALT_EXPERIMENT) BETWEEN.5 0.3e0 AND 0.4e0,
    EXPOSED_TO_EXPERIMENT := NOT IN_LT_HOLDOUT AND IN_FEATURE_TOGGLE AND IN_STAT_EXPOSURE
Result
USER_ID BIGINTIN_LT_HOLDOUT BOOLEANIN_FEATURE_TOGGLE BOOLEANIN_STAT_EXPOSURE BOOLEANEXPOSED_TO_EXPERIMENT BOOLEAN
35falsetruetruetrue
36falsetruefalsefalse
37falsetruetruetrue
38truetruetruefalse
39falsetruefalsefalse
40falsetruefalsefalse

The pattern generalizes to any number of variants. For an A/B/C test, partition the 0-to-1 range into segments:

VARIANT := CASE
    WHEN HASH01(USER_ID::VARCHAR || 'experiment_v3') < 0.33e0 THEN 'variant_a'
    WHEN HASH01(USER_ID::VARCHAR || 'experiment_v3') < 0.66e0 THEN 'variant_b'
    ELSE 'control'
END
sql

To expand an experiment's population over time while keeping existing assignments stable, use the same salt and widen the threshold:

-- Phase 1: 20% of users
IN_PHASE_1 := HASH01(USER_ID::VARCHAR || 'experiment_v3') < 0.2e0
-- Phase 2: 40% of users (phase 1 users are still included)
IN_PHASE_2 := HASH01(USER_ID::VARCHAR || 'experiment_v3') < 0.4e0
sql

Time-based deployment with GRADUAL_ROLLOUT()

GRADUAL_ROLLOUT() adds a time dimension to bucketing. Instead of flipping a feature on for X% of users all at once, it smoothly transitions from 0% to 100% over a defined window — giving you time to monitor for issues before full exposure.

GRADUAL_ROLLOUT(key, time_ref, time_0pc, time_100pc, power, chunks) -> BOOLEAN
sql

The key works like HASH01() — it determines which users are enabled first. The time_0pc and time_100pc timestamps define the rollout window. The power parameter controls the curve shape (1.0 = linear), and chunks sets how many discrete steps the rollout uses.

This linear rollout enables users in 8 chunks over an 8-hour window. Notice how each chunk adds roughly 11% of users, and once a user is enabled they stay enabled:

FeatureQL
/* SQL */
SELECT POINT_IN_TIME_F, COUNT_IF(ROLLOUT) / COUNT(1) as PERCENT_EXPOSED
FROM FEATUREQL /*
    WITH
        USER_ID := INPUT(BIGINT),
        POINT_IN_TIME := INPUT(TIMESTAMP),
    SELECT
        USER_ID,
        DATE_FORMAT(POINT_IN_TIME, '%Y-%m-%dT%H:%M:%S') as POINT_IN_TIME_F,
        GRADUAL_ROLLOUT(
            'SALT_ROLLOUT' || CAST(USER_ID AS VARCHAR),
            POINT_IN_TIME,
            TIMESTAMP '2025-04-20 10:00:00',
            TIMESTAMP '2025-04-20 18:00:00',
            1e0,
  			8
        ) as ROLLOUT
    FOR CROSS
        USER_ID := BIND_VALUES(SEQUENCE(1,10000)),
        POINT_IN_TIME := BIND_VALUES(ARRAY_CONCAT(
            SEQUENCE(TIMESTAMP '2025-04-20 09:59:59', TIMESTAMP '2025-04-20 17:59:59', INTERVAL '1 HOURS'),
            SEQUENCE(TIMESTAMP '2025-04-20 10:00:00', TIMESTAMP '2025-04-20 18:00:00', INTERVAL '1 HOURS'),
            SEQUENCE(TIMESTAMP '2025-04-20 10:00:01', TIMESTAMP '2025-04-20 18:00:01', INTERVAL '1 HOURS')
        ))
*/
GROUP BY 1
ORDER BY 1
Result
POINT_IN_TIME_F VARCHARPERCENT_EXPOSED VARCHAR
2025-04-20T09:59:590.0
2025-04-20T10:00:000.112
2025-04-20T10:00:010.112
2025-04-20T10:59:590.112
2025-04-20T11:00:000.2222
2025-04-20T11:00:010.2222
2025-04-20T11:59:590.2222
2025-04-20T12:00:000.3313
2025-04-20T12:00:010.3313
2025-04-20T12:59:590.3313
2025-04-20T13:00:000.4371
2025-04-20T13:00:010.4371
2025-04-20T13:59:590.4371
2025-04-20T14:00:000.5492
2025-04-20T14:00:010.5492
2025-04-20T14:59:590.5492
2025-04-20T15:00:000.6572
2025-04-20T15:00:010.6572
2025-04-20T15:59:590.6572
2025-04-20T16:00:000.7696
2025-04-20T16:00:010.7696
2025-04-20T16:59:590.7696
2025-04-20T17:00:000.8907
2025-04-20T17:00:010.8907
2025-04-20T17:59:590.8907
2025-04-20T18:00:001.0
2025-04-20T18:00:011.0

Setting power = 2.0 makes the rollout start slower and accelerate later — useful for high-risk features where you want more time to detect issues at low exposure:

FeatureQL
/* SQL */
SELECT POINT_IN_TIME_F, COUNT_IF(ROLLOUT) / COUNT(1) as PERCENT_EXPOSED
FROM FEATUREQL /*
    WITH
        USER_ID := INPUT(BIGINT),
        POINT_IN_TIME := INPUT(TIMESTAMP),
    SELECT
        USER_ID,
        DATE_FORMAT(POINT_IN_TIME, '%Y-%m-%dT%H:%M:%S') as POINT_IN_TIME_F,
        GRADUAL_ROLLOUT(
            'SALT_ROLLOUT' || CAST(USER_ID AS VARCHAR),
            POINT_IN_TIME,
            TIMESTAMP '2025-04-20 10:00:00',
            TIMESTAMP '2025-04-20 18:00:00',
            2e0,
  			8
        ) as ROLLOUT
    FOR CROSS
        USER_ID := BIND_VALUES(SEQUENCE(1,10000)),
        POINT_IN_TIME := BIND_VALUES(
            SEQUENCE(TIMESTAMP '2025-04-20 10:00:00', TIMESTAMP '2025-04-20 18:00:00', INTERVAL '1 HOUR')
        )
*/
GROUP BY 1
ORDER BY 1
Result
POINT_IN_TIME_F VARCHARPERCENT_EXPOSED VARCHAR
2025-04-20T10:00:000.112
2025-04-20T11:00:000.112
2025-04-20T12:00:000.112
2025-04-20T13:00:000.2222
2025-04-20T14:00:000.3313
2025-04-20T15:00:000.4371
2025-04-20T16:00:000.5492
2025-04-20T17:00:000.7696
2025-04-20T18:00:001.0

Combining experiments and rollouts

In practice, you layer multiple conditions to control exactly who sees what:

EXPOSED :=
    NOT IN_HOLDOUT                  -- Not in global holdout (HASH01)
    AND IS_ELIGIBLE                 -- Meets business criteria (feature toggle)
    AND IN_EXPERIMENT_POPULATION    -- Statistically assigned (HASH01)
    AND IN_ROLLED_OUT_POPULATION    -- Time-based rollout active (GRADUAL_ROLLOUT)
sql

The holdout ensures you always have a clean control group. The feature toggle handles business rules (country, segment). The experiment assignment handles statistical bucketing. The rollout handles safe deployment. Each layer is independent because each uses a different salt.

Choosing a rollout strategy

StrategyPowerBehaviorBest for
Linear1.0Steady, even progressionDefault choice
Conservative> 1.0Slow start, fast finishHigh-risk features
Aggressive< 1.0Fast start, slow finishLow-risk features
SteppedFew chunksLong plateaus between jumpsContinuous monitoring
Last update at: 2026/03/03 16:47:38
Last updated: 2026-03-03 16:48:19