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 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:
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| USER_ID BIGINT | IN_LT_HOLDOUT BOOLEAN | IN_FEATURE_TOGGLE BOOLEAN | IN_STAT_EXPOSURE BOOLEAN | EXPOSED_TO_EXPERIMENT BOOLEAN |
|---|---|---|---|---|
| 35 | false | true | true | true |
| 36 | false | true | false | false |
| 37 | false | true | true | true |
| 38 | true | true | true | false |
| 39 | false | true | false | false |
| 40 | false | true | false | false |
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 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 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 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:
/* 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| POINT_IN_TIME_F VARCHAR | PERCENT_EXPOSED VARCHAR |
|---|---|
| 2025-04-20T09:59:59 | 0.0 |
| 2025-04-20T10:00:00 | 0.112 |
| 2025-04-20T10:00:01 | 0.112 |
| 2025-04-20T10:59:59 | 0.112 |
| 2025-04-20T11:00:00 | 0.2222 |
| 2025-04-20T11:00:01 | 0.2222 |
| 2025-04-20T11:59:59 | 0.2222 |
| 2025-04-20T12:00:00 | 0.3313 |
| 2025-04-20T12:00:01 | 0.3313 |
| 2025-04-20T12:59:59 | 0.3313 |
| 2025-04-20T13:00:00 | 0.4371 |
| 2025-04-20T13:00:01 | 0.4371 |
| 2025-04-20T13:59:59 | 0.4371 |
| 2025-04-20T14:00:00 | 0.5492 |
| 2025-04-20T14:00:01 | 0.5492 |
| 2025-04-20T14:59:59 | 0.5492 |
| 2025-04-20T15:00:00 | 0.6572 |
| 2025-04-20T15:00:01 | 0.6572 |
| 2025-04-20T15:59:59 | 0.6572 |
| 2025-04-20T16:00:00 | 0.7696 |
| 2025-04-20T16:00:01 | 0.7696 |
| 2025-04-20T16:59:59 | 0.7696 |
| 2025-04-20T17:00:00 | 0.8907 |
| 2025-04-20T17:00:01 | 0.8907 |
| 2025-04-20T17:59:59 | 0.8907 |
| 2025-04-20T18:00:00 | 1.0 |
| 2025-04-20T18:00:01 | 1.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:
/* 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| POINT_IN_TIME_F VARCHAR | PERCENT_EXPOSED VARCHAR |
|---|---|
| 2025-04-20T10:00:00 | 0.112 |
| 2025-04-20T11:00:00 | 0.112 |
| 2025-04-20T12:00:00 | 0.112 |
| 2025-04-20T13:00:00 | 0.2222 |
| 2025-04-20T14:00:00 | 0.3313 |
| 2025-04-20T15:00:00 | 0.4371 |
| 2025-04-20T16:00:00 | 0.5492 |
| 2025-04-20T17:00:00 | 0.7696 |
| 2025-04-20T18:00:00 | 1.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) 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
| Strategy | Power | Behavior | Best for |
|---|---|---|---|
| Linear | 1.0 | Steady, even progression | Default choice |
| Conservative | > 1.0 | Slow start, fast finish | High-risk features |
| Aggressive | < 1.0 | Fast start, slow finish | Low-risk features |
| Stepped | Few chunks | Long plateaus between jumps | Continuous monitoring |