You rerun an experiment report, and half the users switch groups. A fresh random draw is useful for some tasks, but it is a poor assignment rule when the same person should keep seeing the same experience.

We will turn a stable user ID into a repeatable bucket with Hashfuncs. Then we will change the rollout size without changing the bucket calculation.

Explore the idea

The bucket stays. The rollout boundary moves.

Keep xxh3_64, seed 42, and the onboarding-v1 prefix fixed while changing who receives treatment.

2 of 8

sample users in treatment

Ben and Hao fall below bucket 20

A 20% share of bucket space does not guarantee exactly 20% of a tiny sample. No user needs to be reshuffled to force that proportion.

Write down the assignment contract

experiment_users8 rows

Eight stable experiment IDs

The text and capitalization are intentional parts of the hash input.

iduser_id
1Ada
2Ben
3Cy
4Dee
5Eli
6Fay
7Gus
8Hao

Our contract is specific: concatenate onboarding-v1: with the exact user ID text, hash it with xxh3_64 and seed 42, then take the remainder modulo 100. The result is an integer from 0 through 99.

The prefix identifies the experiment. The algorithm, seed, text representation, and bucket count all participate in assignment. Changing any of them can move users, so keep the contract versioned and test known input/output pairs across implementation upgrades.

Calculate buckets independently

Use an explicit input, algorithm, seed, and bucket count

SELECT user_id,
  (xxh3_64('onboarding-v1:' || user_id, 42) % 100)::INTEGER AS bucket
FROM experiment_users ORDER BY user_id;
Expected result · sample data8 rows
user_id bucket
Ada 83
Ben 4
Cy 45
Dee 65
Eli 73
Fay 83
Gus 77
Hao 10

Ben lands in bucket 4, Hao in 10, and Cy in 45. Ada and Fay both land in 83. Sharing a bucket is expected: we deliberately map many possible IDs into just 100 buckets. A bucket is not a unique identifier.

Each calculation depends only on that user’s input and the fixed contract. It does not depend on how many other rows are present or on their order. A row-number-based allocation would have a different property: inserting a user could move everybody after them.

Use a non-NULL, stable identifier for the assignment unit you intend. If the experiment is per account, hash the account ID. If it is per person, a rotating session ID would make the experience change between visits.

Separate assignment from rollout size

Grow the treatment group without changing the hash contract

SELECT user_id, bucket,
  CASE WHEN bucket < 20 THEN 'treatment' ELSE 'control' END AS rollout_20,
  CASE WHEN bucket < 50 THEN 'treatment' ELSE 'control' END AS rollout_50
FROM assignments ORDER BY user_id;
Expected result · sample data8 rows
user_id bucket rollout_20 rollout_50
Ada 83 control control
Ben 4 treatment treatment
Cy 45 control treatment
Dee 65 control control
Eli 73 control control
Fay 83 control control
Gus 77 control control
Hao 10 treatment treatment

Scroll for more columns →

At a threshold of 20, buckets 0–19 receive treatment. At 50, buckets 0–49 receive it. Ben and Hao remain treated; Cy joins them. The actual bucket values stay fixed.

“20% rollout” describes the portion of the bucket space allocated to treatment. It does not guarantee exactly 20% of a small sample. Here two of eight users are treated at the smaller threshold, and three of eight at the larger one. Do not reshuffle users just to make a tiny sample look perfectly balanced.

The Hashfuncs reference exposes named algorithms and seed parameters. Reproducibility requires using the same input bytes and algorithm semantics wherever the assignment is computed.

Add a new user

Add Ivy without renumbering the existing users

WITH expanded AS (
 SELECT user_id FROM experiment_users UNION ALL SELECT 'Ivy'
)
SELECT user_id,
  (xxh3_64('onboarding-v1:' || user_id, 42) % 100)::INTEGER AS bucket
FROM expanded ORDER BY user_id;
Expected result · sample data9 rows
user_id bucket
Ada 83
Ben 4
Cy 45
Dee 65
Eli 73
Fay 83
Gus 77
Hao 10
Ivy 11

Ivy lands in bucket 11. The original eight users keep their values. This is why deterministic assignment is useful for an audience that grows over time.

The experiment name is part of the input so another experiment can use a different assignment. That is a reproducibility convention, not a proof of statistical independence. Evaluate allocation quality and sample balance for the experiment you actually run.

Your turn · check your answer

Find users newly included by the larger rollout

Return user_id and bucket for users who move from control at 20% to treatment at 50%.

See the expected result
Your target result1 row
user_id bucket
Cy 45
Sample data is loaded for you.

Preparing the editor…

Need a hint?

Their bucket must be at least 20 and less than 50. Already-treated users should not be counted again.

Show a solution
SELECT user_id, bucket FROM assignments WHERE bucket >= 20 AND bucket < 50 ORDER BY user_id;

Be precise about what stays stable

The bucket is stable under this fixed contract. The treatment group is stable only while its threshold and eligibility rules are stable too. Increasing the threshold intentionally changes some people’s experience; decreasing it can move them back. For a fixed A/B test, persist enrollment and keep the protocol fixed instead of treating a rollout slider as an analysis strategy.

These hashes are non-cryptographic and do not anonymize user IDs. They are also not an access-control mechanism: a caller who chooses their own ID can search for a preferred bucket.

The useful promise is narrower and practical: the same agreed input produces the same assignment calculation, making a report and a product implementation easier to reconcile.