Current section

Files

Jump to
uxid README.md
Raw

README.md

# UXID
[![MIT License][badge_license_url]](LICENSE)
[![Hex Version][badge_version_url]](https://hex.pm/packages/uxid)
[![Hex Downloads][badge_downloads_url]](https://hex.pm/packages/uxid)
**U**ser e**X**perience focused **ID**entifiers (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.
## Usage
### Generating UXIDs
```elixir
# 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"
```
### Ecto
UXIDs can be used as Ecto fields including primary keys.
```elixir
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
end
```
### Validation
By 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`.
```elixir
@primary_key {:id, UXID, autogenerate: true, prefix: "org", size: :medium, validate: true}
schema "organizations" do
field :owner_org_id, UXID, prefix: "org", validate: true
end
```
UUID 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:
```elixir
UXID.valid?("org_01emdgjf0dqxqj8fm78xe97y3h") # => true
UXID.valid?("org_01emdgjf0dqxqj8fm78xe97y3h", prefix: "usr") # => false
UXID.valid?("not-a-uxid") # => false
```
`valid?/2` checks *structure* (prefix + Crockford Base32 body), not authenticity,
and deliberately does not accept bare UUIDs — that coexistence lives in `cast/2`.
### Configuration
#### Case
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.
```elixir
# 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"
```
#### Minimum Size
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.
```elixir
# 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 14
```
When configured, any requested size smaller than `:min_size` will be automatically upgraded. Larger sizes are not affected.
#### Compact Time
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:**
```elixir
# 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:**
```elixir
# 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 randomness
```
**In Ecto Schemas:**
```elixir
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
end
```
**How 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
#### Monotonic IDs
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 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`).
```elixir
# 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`):
```elixir
# config/config.exs
config :uxid, monotonic: true
# or only for specific sizes:
config :uxid, monotonic: [:small, :medium]
```
**In Ecto schemas:**
```elixir
field :id, UXID, autogenerate: true, prefix: "evt", size: :small, monotonic: true
```
**Scope 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`/`: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 under Compact Time — uniqueness and
sorting are unaffected, but decoding the timestamp back out of a monotonic `:xs`
is unreliable.
## Prefix Registry
A prefix only pays off — "the ID names its resource on sight" — when it is
globally unique and well-formed across your whole app. `UXID.Registry` is an
opt-in, compile-time DSL that makes those guarantees the compiler's job instead
of a hand-rolled CI test, and turns the same declarations into a runtime
routing table (prefix → schema) for the ID-driven patterns Adam Kirk describes
in his ElixirConf US 2025 talk, [_UXIDs in Elixir/Ecto_][uxid_talk_url]
(authorization/IDOR checks, admin auto-linking, Relay global IDs).
Declare one registry module as your single source of truth:
```elixir
defmodule MyApp.IDs do
use UXID.Registry,
default_size: :medium,
default_validate: true
defid :org, prefix: "org", schema: MyApp.Org, category: :account
defid :contact, prefix: "contact", size: :large, schema: MyApp.CRM.Contact
defid :lead, prefix: "lead"
retired "usr" # reserve a prefix so it stays unique-checked, never reused
end
```
**Compile-time guarantees.** Every prefix is checked against `:prefix_format`
(overridable; the default permits an internal underscore for compound prefixes
like `in_ref`), and all prefixes — active *and* `retired` — are checked for
uniqueness. A malformed or duplicate prefix is a compile error, so the governance
every prefixed-ID scheme needs ships in the library.
**By key** — minting and schema configuration:
```elixir
MyApp.IDs.generate!(:org) # => "org_01h…"
MyApp.IDs.prefix(:org) # => "org"
MyApp.IDs.size(:org) # => :medium
MyApp.IDs.schema(:org) # => MyApp.Org
MyApp.IDs.all() # => [%{key: :org, prefix: "org", schema: MyApp.Org, ...}, ...]
```
`field_opts/1` is the single-source-of-truth hook — a schema spreads it instead
of restating prefix/size/validate anywhere:
```elixir
@primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:org)}
```
**By ID string** — the runtime routing table (the "which resource is this?" map):
```elixir
MyApp.IDs.known?("org_01h…") # => true (cheap prefix-only membership check)
MyApp.IDs.key_for("org_01h…") # => :org
MyApp.IDs.schema_for("org_01h…") # => MyApp.Org
MyApp.IDs.resolve("org_01h…") # => %{key: :org, schema: MyApp.Org, category: :account, ...}
```
Lookups split an ID on the **last** delimiter, which is unambiguous without any
registry lookup because a UXID body is Crockford Base32 and never contains the
delimiter — so `in_ref_01h…` recovers the `in_ref` prefix cleanly. For that
reason the `:delimiter` must be a character that cannot appear in a Base32 body
(`"_"` — the default — or `"-"`); an underscore is preferred for compound
prefixes since it does not break double-click-to-select-the-whole-id.
### Sharing the registry across sources (JSON manifest)
UXIDs are source-agnostic — you can mint them in Postgres with `INSERT ... SELECT`
or on a mobile/JS client that generates an ID offline before upload. To keep the
Elixir registry the single source of truth in those places too, export a JSON
manifest and let the other runtime read it:
```elixir
MyApp.IDs.manifest()
# => [%{"key" => "org", "prefix" => "org", "size" => "medium", "category" => "account"}, ...]
MyApp.IDs.manifest_json()
# => ~s([{"key":"org","prefix":"org","size":"medium","category":"account"}, ...])
```
`manifest/0` returns plain JSON-safe data (string keys, scalar values, `nil` for
unset fields) that you can hand to any JSON library; `manifest_json/0` returns a
ready-to-write string with no extra dependency. A common pattern is a tiny Mix
task or release step that writes it to a file your database migrations or client
build consume, so every generator agrees on prefixes and sizes:
```elixir
# lib/mix/tasks/uxid.manifest.ex
defmodule Mix.Tasks.Uxid.Manifest do
use Mix.Task
@shortdoc "Writes the UXID prefix manifest to priv/uxid_manifest.json"
def run(_args) do
File.write!("priv/uxid_manifest.json", MyApp.IDs.manifest_json())
end
end
```
The manifest carries `prefix`, `size` (which fixes the random length), `category`,
and `key`; combine each `prefix` with the registry's delimiter and a Base32 body
to assemble an ID anywhere.
## Installation
The package can be installed by adding `uxid` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:uxid, "~> 2.5"}
]
end
```
Online documenttion can be found at [https://hexdocs.pm/uxid][hexdocs_project_url].
<!-- LINKS -->
[hex_project_url]: https://hex.pm/packages/uxid
[hexdocs_project_url]: https://hexdocs.pm/uxid
[mit_license_url]: http://opensource.org/licenses/MIT
[uxid_talk_url]: https://www.youtube.com/watch?v=YIIJClhjxOA
<!-- BADGES -->
[badge_license_url]: https://img.shields.io/badge/license-MIT-brightgreen.svg?cacheSeconds=3600?style=flat-square
[badge_downloads_url]: https://img.shields.io/hexpm/dt/uxid?style=flat&logo=elixir
[badge_version_url]: https://img.shields.io/hexpm/v/uxid?style=flat&logo=elixir