Monotonic IDs
View SourceStandard UXIDs draw fresh randomness for every ID, so collision risk within a
single millisecond is a birthday problem — for small sizes (:xsmall has 0
random bits, :small has 16) a burst of same-millisecond IDs can collide.
Monotonic mode fixes this the way the ULID monotonic spec does: within a
millisecond it seeds the random field once from the CSPRNG, then advances it by a
random positive step for each subsequent ID. That turns "birthday collision
among N draws" into "guaranteed distinct until the field overflows," and because
the field is encoded big-endian, a strictly increasing counter also sorts
strictly after — K-sortability is preserved at every size.
The step is uniform over [1, 2^(bits/2)] (the square root of the field space,
auto-derived from the size — no configuration). Stepping by a random amount
instead of exactly +1 means an attacker who sees …0004 can no longer guess
…0005: single-shot guess cost rises from certainty to ~1/2^(bits/2) (1/256 at
:small, ~1/10⁶ at :medium), while still leaving ample same-ms burst headroom
before overflow (~512 IDs/ms at :small, ~2M at :medium).
Enabling it
# Per-call: on for all sizes
UXID.generate!(size: :small, monotonic: true)
# Per-size list (alias-aware: :small also matches :s, :medium matches :m, ...)
UXID.generate!(size: :small, monotonic: [:small, :medium])Global policy (overridable per-call/per-field, mirrors compact_small_times):
# config/config.exs
config :uxid, monotonic: true
# or only for specific sizes:
config :uxid, monotonic: [:small, :medium]In Ecto schemas:
field :id, UXID, autogenerate: true, prefix: "evt", size: :small, monotonic: trueScope of the guarantee
Monotonic and collision-free within a single BEAM process. State lives in the
process dictionary (keyed by prefix and field size) — no GenServer, no ETS, no
shared state, so it is async: true safe. Each process gets an independent
random starting point per millisecond, so cross-process collisions fall back to a
birthday probability on the field size.
Tradeoff (why it is opt-in)
The random step is a mitigation, not cryptographic unpredictability. It removes
trivial +1 enumeration, but an attacker who observes two consecutive same-ms
IDs learns the actual gap, and values still lie in a bounded window ahead.
Low-entropy sizes stay low-entropy, so monotonic must be a conscious per-resource
choice and is never a silent default.
Don't use small monotonic IDs as public, enumerable identifiers
Don't use :small/:medium monotonic IDs as externally-enumerable,
security-sensitive identifiers; prefer :large/:xl (and non-monotonic) there.
:xs / :xsmall note
A standard :xs has 0 random bits — nothing to increment — so when monotonic is
active compact_time is enabled automatically for :xs/:xsmall, yielding a
1-byte (8-bit) counter field. Passing an explicit compact_time: false on
:xs/:xsmall with monotonic on raises an ArgumentError (there would be no
field to count). This inherits the compact :xsmall time-decode ambiguity
described in the Sizes & Encoding guide — uniqueness and
sorting are unaffected, but decoding the timestamp back out of a monotonic :xs
is unreliable.