Skip to main content

Buckets

Percentage-bucket segments assign each entity to a deterministic bucket in [0, 9999] based on a hash of its identity, then test whether that bucket falls within the segment's configured range. They are how exd expresses gradual rollouts, A/B/C splits, and any audience that should expand or contract by percentage over time.

This page is the reference for the [segment.bucket] table — fields, hash algorithm, salt-sharing pattern, predicate-bucket hybrids, and range arithmetic.


The [segment.bucket] table

[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 0
end = 999

Field reference

FieldTypeRequiredDefaultValidationDescription
entity_id_attributestringyesNon-emptyPath into the evaluation context whose value is hashed to assign the bucket.
saltstringnothe segment key (defaulted at evaluation time only)Empty or absent fires W004Salt mixed into the hash input. Lets multiple bucket segments share an assignment space.
startintegeryes0 ≤ start ≤ end ≤ 9999Inclusive lower bound of the bucket range.
endintegeryes0 ≤ start ≤ end ≤ 9999Inclusive upper bound of the bucket range.

E006 fires when:

  • entity_id_attribute is missing or empty.
  • start is missing or < 0.
  • end is missing or > 9999.
  • start > end.

W004 fires when salt is missing or empty (the segment still works — it falls back to using the segment key at evaluation time — but the warning makes the implicit choice explicit).

Unknown fields under [segment.bucket] (e.g., stat = 0 instead of start, entitiy_id_attribute, an unrecognized future-version key) produce E016. The strict-by-default policy catches typos that would otherwise silently rebucket every entity.


The hash algorithm

For any (entity, segment) pair, compute the bucket as follows:

  1. Read the entity identifier. Look up segment.bucket.entity_id_attribute in the evaluation context. If the attribute is absent, its value is not a string, or its value is the empty string "", the entity is not a member of the segment. Return false.
  2. Compute the hash input. Let salt be segment.bucket.salt if present and non-empty, otherwise the segment key. The hash input is the UTF-8 byte string <salt> + "/" + <entity_id>. The separator is the literal ASCII slash character (0x2F).
  3. Hash. Apply MurmurHash3 32-bit (the MurmurHash3_x86_32 variant) with seed 0 to the UTF-8 hash-input bytes. The output is a 32-bit unsigned integer.
  4. Reduce to a bucket. The bucket is hash % 10000. The result is in [0, 9999], providing 0.01% granularity.
  5. Test membership. The entity is a member iff start ≤ bucket ≤ end.

Both the server and every conformant SDK MUST produce the same bucket for the same (salt, entity_id) pair. Parity is enforced via the parity-fixture suite at fixtures/evaluation-parity.json.

Algorithm summary

input = utf8_bytes(salt + "/" + entity_id)
hash = murmurhash3_32(input, seed = 0)
bucket = hash % 10000
member = start <= bucket <= end

Example

For salt = "checkout-redesign", entity_id = "user_42", the input is the byte string checkout-redesign/user_42 (25 ASCII bytes). MurmurHash3 32-bit with seed 0 yields a deterministic value; modulo 10000 gives a bucket. Concrete numeric examples should be derived from the parity fixtures — a hand-computed example here would risk drifting away from the reference implementation.

Why empty entity_id is a non-match

If the attribute value is the empty string, the entity is not a member of any bucket segment. Hashing an empty identifier would place every "anonymous" entity into the same deterministic bucket and quietly skew percentage rollouts. Forcing such evaluations to fall through is the safer default.


The salt and shared assignment spaces

The salt is what distinguishes one bucket assignment space from another. Two segments that use the same salt place every entity at the same bucket; two segments that use different salts assign entities independently.

Shared salt — three-way A/B/C split

# segments/checkout-treatment-a-bucket.toml
[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 0
end = 999
# segments/checkout-treatment-b-bucket.toml
[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 1000
end = 1999
# segments/checkout-treatment-c-bucket.toml
[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 2000
end = 2999

All three segments use the same salt, so the same hash bucket is computed for every entity; the three ranges partition the 0–2999 portion of the assignment space (the rest of the space is in none of the three segments and falls through to the flag's default variant). A flag's rules can map each segment to a distinct variant.

Independent salt — uncorrelated rollouts

Two unrelated experiments should use different salts so that an entity bucketed into "treatment" for one experiment is not statistically more likely to be in "treatment" for the other. A common pattern is to use the experiment name (or a year-prefixed name) as the salt:

salt = "checkout-redesign-2025"
salt = "search-rerank-2026q1"
salt = "billing-cycle-rewrite"

Stable salts and renames

Because the salt defaults to the segment key (its filename stem), renaming a segment file without setting an explicit salt rebuckets every entity. To keep bucket assignments stable across renames, always set an explicit salt and treat it as immutable. The W004 warning is the linter's nudge to do this.


Bucket constrained by predicate

A segment MAY declare both [segment.predicate] and [segment.bucket]. The entity is a member iff both the predicate is true and the bucket is in range:

schema_version = "0.1"

[segment]
description = "First 10% bucket within beta users"

[segment.predicate]
segment = "beta-users"

[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 0
end = 999

This expresses "the first 10% of users (by deterministic hash) within the beta cohort." A flag rule can then assign a variant to that constrained audience:

[[flag.environments.production.rules]]
description = "Variant A for the first 10% of beta users"
segment = "beta-users-variant-a-bucket"
variant = "variant_a"

Evaluation order inside a hybrid segment

The predicate is evaluated first. If it returns false, the bucket hash is not computed. This is the canonical order: predicates are typically cheaper than hashing, and skipping the hash when the predicate fails is also privacy-preserving (no entity_id is hashed for entities outside the predicate's scope).


Range arithmetic

Because start and end are inclusive integers in [0, 9999]:

RangeBucketsPercentage
start = 0, end = 9991000 buckets10.00%
start = 0, end = 999910000 buckets100.00%
start = 9999, end = 99991 bucket0.01%
start = end (any value)1 bucket0.01%

Coverage formula:

coverage = (end - start + 1) / 10000

Plan ranges with this formula, not the intuitive "10% means end = 1000" mistake — that would be 11 buckets, or 0.11%.


Multi-step rollouts

A common pattern is to declare one segment per rollout step and update it as the rollout grows:

# segments/checkout-redesign-rollout.toml — Day 0: 10%
[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 0
end = 999

When ready to expand to 25%:

# Day 14: 25%
[segment.bucket]
entity_id_attribute = "user.id"
salt = "checkout-redesign-2025"
start = 0
end = 2499

Because the salt is unchanged and start = 0, every entity already in the segment remains in the segment — the rollout monotonically expands without rebucketing. Decreasing end (rolling back) similarly removes only entities at the high end of the range, not random subsets.

Pause without rebucketing

To roll an audience out entirely without rebucketing — in case you want to roll back in later — set start = end = 0 and disable the rule rather than deleting the segment. The next rollout-in will pick up the same bucket assignments.


Common diagnostics

ScenarioDiagnostic
entity_id_attribute missing or emptyE006
start missingE006
end missingE006
start < 0E006
end > 9999E006
start > endE006
salt missing or emptyW004
start = 0, end = 0 (single bucket)accepted
start = 0, end = 9999 (full range)accepted
Bucket alongside predicate (both present, both well-formed)accepted
bucket and predicate both absentE011 (segment-level)
Unknown field under [segment.bucket] (e.g., stat = 0)E016
entity_id_attribute referenced as a non-string elsewhere (e.g., eq with an integer)E034

  • Always set an explicit salt equal to a stable rollout or experiment name. The W004 warning is informative; treating it as a CI failure is reasonable.
  • Salt names SHOULD include a year or quarter qualifier (-2025, -q2-26) so a future experiment with a similar name does not collide.
  • Plan the percentage covered using the inclusive formula. A 10% rollout is end = 999, not end = 1000.
  • Roll out by extending end (with start unchanged) to monotonically expand the audience. This preserves the in-bucket population.
  • Roll back by reducing end symmetrically. Avoid changing start mid-experiment; doing so reshuffles which entities are in the segment.
  • Keep the salt out of UI-visible names. The salt is a hashing detail; the segment key is the audience name.

See also

  • segment — where [segment.bucket] lives, including the three segment shapes.
  • predicates — the predicate side of hybrid segments.
  • evaluation-contextentity_id_attribute lookup, missing-value behavior, the empty-string rule.
  • resolution — how a flag rule consumes a bucket segment's membership at evaluation time.
  • diagnosticsE006, E011, E016, E034, W004.