User eXperience focused IDentifiers (UXIDs) are identifiers which:
- Describe the resource (aid in debugging and investigation)
- Work well with copy and paste (double clicking selects the entire ID)
- Can be shortened for low cardinality resources
- Are secure against enumeration attacks
- Can be generated by application code (not tied to the datastore)
- Are K-sortable (lexicographically sortable by time - works well with datastore indexing)
- Do not require any coordination (human or automated) at startup, or generation
- Are very unlikely to collide (more likely with less randomness)
- Are easily and accurately transmitted to another human using a telephone
Many of the concepts of Stripe IDs have been used in this library.
# No options generates a basic ULID
UXID.generate! # "01emdgjf0dqxqj8fm78xe97y3h"
# A prefix can be provided
UXID.generate! prefix: "cus" # "cus_01emdgjf0dqxqj8fm78xe97y3h"
# The amount of randomness can be decreased for smaller cardinality resources
# T-Shirt sizes can be used (xs, s, m, l, xl) or (xsmall, small, medium, large, xlarge)
UXID.generate! prefix: "cus", size: :small # "cus_01eqrh884aqyy1"
# Compact time mode trades timestamp precision for more randomness (good for collision resistance)
UXID.generate! prefix: "sess", size: :small, compact_time: true # "sess_kf3ng7s1mf41b"
# Uppercase can be used to match previous UXID versions
UXID.generate! case: :upper # "01EMDGJF0DQXQJ8FM78XE97Y3H"UXIDs can be used as Ecto fields including primary keys.
defmodule YourApp.User do
use Ecto.Schema
@primary_key {:id, UXID, autogenerate: true, prefix: "usr", size: :medium}
schema "users" do
field :api_key, UXID, autogenerate: true, prefix: "apikey", size: :small, compact_time: true
field :api_secret, UXID, autogenerate: true, prefix: "apisecret", size: :xlarge
end
endBy default cast/2 accepts any binary unchanged, which is ideal while a schema
still holds a mix of legacy identifiers. A field can opt in to strict validation
with validate: true, in which case a value must be either a structurally valid
UXID carrying the field's configured :prefix, or a legacy bare UUID string
(canonical 36-character form). Anything else — an empty string, the wrong prefix,
non–Base32 characters — casts to :error.
@primary_key {:id, UXID, autogenerate: true, prefix: "org", size: :medium, validate: true}
schema "organizations" do
field :owner_org_id, UXID, prefix: "org", validate: true
endUUID coexistence is on by default so a table can migrate its column type from
uuid to text without rewriting existing rows. Turn it off with
allow_uuid: false once a table holds only UXIDs.
You can also validate a string directly, without Ecto:
UXID.valid?("org_01emdgjf0dqxqj8fm78xe97y3h") # => true
UXID.valid?("org_01emdgjf0dqxqj8fm78xe97y3h", prefix: "usr") # => false
UXID.valid?("not-a-uxid") # => falsevalid?/2 checks structure (prefix + Crockford Base32 body), not authenticity,
and deliberately does not accept bare UUIDs — that coexistence lives in cast/2.
The :case config option controls the default case for generated UXIDs. By default, UXIDs are lowercase (:lower), but you can configure uppercase (:upper) globally or per-call.
# config/config.exs
config :uxid, case: :upper
# All generated UXIDs will be uppercase by default
UXID.generate!()
# => "01EMDGJF0DQXQJ8FM78XE97Y3H"
# Override per-call if needed
UXID.generate!(case: :lower)
# => "01emdgjf0dqxqj8fm78xe97y3h"The :min_size config option enforces a minimum UXID size regardless of what size is requested. This is useful in test environments where many IDs are generated rapidly, as smaller sizes have limited randomness that can cause duplicate key violations.
# config/test.exs
config :uxid, min_size: :medium
# In application code - requests :small but gets :medium in test env
UXID.generate!(prefix: "usr", size: :small)
# => Returns 18 character UXID instead of 14When configured, any requested size smaller than :min_size will be automatically upgraded. Larger sizes are not affected.
The :compact_small_times config option and per-call compact_time option provide improved collision resistance for small UXIDs by using shorter timestamps and more randomness.
Global Policy:
# config/test.exs
config :uxid, compact_small_times: true
# Automatically compacts :xs/:xsmall and :s/:small sizes
UXID.generate!(size: :small)
# => 13 chars (8 time + 5 rand = 24 bits random vs 16 bits standard)Per-Call Override:
# Override for any size - works even when global policy is off
UXID.generate!(size: :large, compact_time: true)
# => 21 chars with extra randomness
# Opt out of global policy for specific calls
UXID.generate!(size: :small, compact_time: false)
# => 14 chars with standard randomnessIn Ecto Schemas:
defmodule YourApp.Session do
use Ecto.Schema
@primary_key {:id, UXID, autogenerate: true, prefix: "sess", size: :small, compact_time: true}
schema "sessions" do
# This session ID will always use compact mode for better collision resistance
end
endHow it works:
- Reduces timestamp from 48 bits (10 chars) to 40 bits (8 chars)
- Frees 8 bits for additional randomness (e.g., :small gets 24 bits vs 16 bits)
- Perfect 5-bit Crockford Base32 alignment
- K-sortability maintained until ~September 2039
- Decoder automatically detects compact format and reconstructs full timestamp using epoch inference
When to use:
- Test environments with rapid ID generation
- Resources with small cardinality that need better collision resistance
- Any scenario where you want to maximize randomness within a given length constraint
Standard 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 increments by
1 for each subsequent ID. That turns "birthday collision among N draws" into
"guaranteed distinct for up to 2^bits draws," and because the field is encoded
big-endian, incrementing also sorts strictly after — K-sortability is preserved
at every size.
# 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): consecutive burst IDs differ by +1 in the low
bits — i.e. guessable. That weakens the "secure against enumeration attacks"
property, so monotonic must be a conscious per-resource choice and is never a
silent default.
: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 under Compact Time — uniqueness and
sorting are unaffected, but decoding the timestamp back out of a monotonic :xs
is unreliable.
The package can be installed by adding uxid to your list of dependencies in mix.exs:
def deps do
[
{:uxid, "~> 2.4"}
]
endOnline documenttion can be found at https://hexdocs.pm/uxid.