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
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
entity_id_attribute | string | yes | — | Non-empty | Path into the evaluation context whose value is hashed to assign the bucket. |
salt | string | no | the segment key (defaulted at evaluation time only) | Empty or absent fires W004 | Salt mixed into the hash input. Lets multiple bucket segments share an assignment space. |
start | integer | yes | — | 0 ≤ start ≤ end ≤ 9999 | Inclusive lower bound of the bucket range. |
end | integer | yes | — | 0 ≤ start ≤ end ≤ 9999 | Inclusive upper bound of the bucket range. |
E006 fires when:
entity_id_attributeis missing or empty.startis missing or< 0.endis 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:
- Read the entity identifier. Look up
segment.bucket.entity_id_attributein 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. Returnfalse. - Compute the hash input. Let
saltbesegment.bucket.saltif 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). - Hash. Apply MurmurHash3 32-bit (the
MurmurHash3_x86_32variant) with seed0to the UTF-8 hash-input bytes. The output is a 32-bit unsigned integer. - Reduce to a bucket. The bucket is
hash % 10000. The result is in[0, 9999], providing 0.01% granularity. - 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]:
| Range | Buckets | Percentage |
|---|---|---|
start = 0, end = 999 | 1000 buckets | 10.00% |
start = 0, end = 9999 | 10000 buckets | 100.00% |
start = 9999, end = 9999 | 1 bucket | 0.01% |
start = end (any value) | 1 bucket | 0.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
| Scenario | Diagnostic |
|---|---|
entity_id_attribute missing or empty | E006 |
start missing | E006 |
end missing | E006 |
start < 0 | E006 |
end > 9999 | E006 |
start > end | E006 |
salt missing or empty | W004 |
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 absent | E011 (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 |
Recommended practice
- Always set an explicit
saltequal to a stable rollout or experiment name. TheW004warning 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, notend = 1000. - Roll out by extending
end(withstartunchanged) to monotonically expand the audience. This preserves the in-bucket population. - Roll back by reducing
endsymmetrically. Avoid changingstartmid-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-context —
entity_id_attributelookup, missing-value behavior, the empty-string rule. - resolution — how a flag rule consumes a bucket segment's membership at evaluation time.
- diagnostics —
E006,E011,E016,E034,W004.