Deterministic IDs

View Source

Standard UXIDs are random: calling generate!/1 twice with the same options gives two different IDs. Deterministic mode is the opposite — the same input string always maps to the same ID, forever, across processes and machines. This is what a name-based UUID (UUIDv5) gives you, adapted to the UXID format.

Reach for it when you want a stable ID derived from an external key without a lookup table: an email, a vendor SKU, a webhook idempotency key, or a natural key during a migration. It makes idempotent upserts trivial (recompute the ID instead of querying for it) and gives you stable fixtures and seeds in tests.

The from: API

Passing from: switches generate/1, generate!/1, and new/1 into deterministic mode. Every other option (prefix:, size:, case:, delimiter:) works unchanged:

UXID.generate!(prefix: "usr", from: "alice@example.com")
# => "usr_zcvt7epac0t1ebcsjfyf7cwz25"  (stable for this input, forever)

UXID.generate!(prefix: "usr", from: "alice@example.com")  # identical again

The ID is a truncated SHA-256 of the input. from: must be a string; pass a non-binary and it raises. For a composite key, stringify it yourself first ("#{tenant}:#{email}") so you control the exact bytes that get hashed.

Prefix is the namespace

The prefix is folded into the hash as the namespace, so the same string under two prefixes produces two unrelated bodies — usr + "alice@example.com" and org

  • "alice@example.com" never collide:
UXID.generate!(prefix: "usr", from: "alice@example.com")  # "usr_zcvt7epac…"
UXID.generate!(prefix: "org", from: "alice@example.com")  # "org_zes4c99fn…"  (unrelated)

Most callers pass just prefix + from and get correct scoping with zero extra config. (There is no separate namespace: option; the prefix does that job. No prefix at all hashes into the global scope.)

The z marker and sort order

A deterministic body always starts with z (or Z in upper case) — Crockford value 31, the maximum symbol. It does two jobs:

  1. Self-identifying — a human or the decoder can see at a glance that the ID is hash-derived, not time-derived. UXID.deterministic?/1 reports it, and decode/1 returns deterministic: true with time: nil.
  2. Sorts last — because z is the highest value, every deterministic ID sorts after every time-based ID. They cluster at the end of an index instead of interleaving with (and polluting) the K-sortable time range. Among themselves they sort in no meaningful order.

To keep z an unambiguous marker, compact-time encoding reserves value 31: a compact timestamp can no longer start with z, which trims the compact horizon to ~mid-2038 (standard 48-bit timestamps are unaffected). See the Sizes & Encoding guide.

Size and length

Deterministic bodies reuse the standard (non-compact) lengths, spending the whole body — minus the one-character marker — on hash bits:

SizeAliasesBody lengthHash bits
:xs:xsmall10 chars45
:s:small14 chars65
:m:medium18 chars85
:l:large22 chars105
:xl:xlarge26 chars125

With no :size, generation defaults to :xl (125 hash bits). A deterministic ID carries more distinguishing bits than the random UXID of the same size, since it does not spend characters on a timestamp. Changing size changes the ID: the same input at two sizes yields unrelated bodies, not one a truncated prefix of the other. Case is a display concern applied at encode time — upper and lower are the same ID, exactly as for random UXIDs.

Properties, honestly

  • Deterministic & idempotent — same {namespace, name, size, case} → same ID, everywhere, forever.
  • Not time-ordered — hash order is effectively random-but-stable. These sort after time IDs and among themselves in no meaningful order, so do not rely on them for K-sortability.
  • Not a secret — a deterministic ID is a hash of a known input, so it is exactly as guessable as that input. Anyone who knows the namespace + name can recompute the ID. Do not derive an ID from a low-entropy secret and treat the ID as unguessable. (This is the same caveat RFC 4122 gives for name-based UUIDs.)

Deterministic IDs are not unguessable

If you need an identifier an attacker cannot guess, use a random UXID (:large or :xl), not a deterministic one. from: is for stability, not secrecy.

With Ecto

Ecto autogenerate is intentionally not wired for from: — there is no per-row input available at autogenerate time. Mint the ID explicitly in application code (typically a changeset) and store it as an ordinary string:

def changeset(user, attrs) do
  user
  |> cast(attrs, [:email])
  |> validate_required([:email])
  |> put_deterministic_id()
end

defp put_deterministic_id(%{valid?: true, changes: %{email: email}} = changeset) do
  put_change(changeset, :id, UXID.generate!(prefix: "usr", from: email))
end

defp put_deterministic_id(changeset), do: changeset

The field itself is a normal UXID (or :string) column — casting and querying are unchanged; you are just supplying the value instead of letting the datastore or autogenerate mint a random one.