If you’re building a new Phoenix application today, deciding on a primary key strategy usually takes about three seconds. You don't want to use standard auto-incrementing integers because they leak your business volume (e.g., "Oh, I'm only user #104? This app is a ghost town").
So, you do what everyone does: you reach for Ecto.UUID. It's built-in, it's unpredictable, and it solves the problem.
But as your application scales, UUIDv4 becomes a silent performance killer. After wrestling with database bloat and terrible API developer experience, we decided to build a better way. Enter Once.
Here is why we ditched UUIDs, and how you can get Stripe-style, high-performance IDs in your Elixir app in about two minutes.
The Problem with UUIDv4: Database Fragmentation
PostgreSQL (like most relational databases) uses B-trees for its indexes. B-trees are incredibly fast, but they have a distinct preference: they absolutely love sequential data.
When you insert a sequential ID (like a standard integer or a time-sorted Snowflake ID), Postgres cleanly appends it to the right edge of the index.
UUIDv4, however, is purely random. When you insert a million random UUIDs, Postgres has to constantly search for the correct leaf node to insert the new value. This causes massive "page splits," fragments the index, and destroys your cache locality.
Over time, your index size balloons, your write I/O skyrockets, and your database spends precious memory caching index pages that are mostly empty.
The API UX Problem: "Type Confusion"
Beyond database performance, UUIDs are completely hostile to human developers.
If you are looking at a log file or an API payload and see 9f1c0d3a-2b7e-49a1-8d5c-9123456789ab, what is it? Is it a User ID? A Payment ID? A Subscription ID? If a frontend developer accidentally passes an Organization ID into a User endpoint, the system will just return a generic 404 Not Found, leading to hours of frustrating debugging.
Stripe solved this years ago by prefixing their IDs: cus_... for customers, ch_... for charges. It is self-documenting, type-safe, and universally loved by developers. We wanted that exact experience in Ecto.
Enter Once: The Best of Both Worlds
We built Once as a custom Ecto type to solve both the database performance problem and the API usability problem, without compromising on security.
Under the hood, Once is powered by NoNoncense, a distributed, lock-free ID generator that uses Erlang's :atomics to generate tens of millions of 64-bit IDs per second. Because it's 64-bit (instead of UUID's 128-bit), it immediately cuts your ID storage footprint in half.
The "Masking" Magic
You usually have to make a painful choice:
- Use sequential integers (Fast database, but leaks business metrics).
- Use random strings (Protects metrics, but slow database).
With Once masking, you don't have to choose. You configure your Ecto schema to generate a sequential, counter-based ID. When Ecto saves the record to your database, it stores a raw, 64-bit integer. Your database is perfectly happy, and your B-tree indexes stay perfectly compact.
But when Ecto loads that record back into your Elixir application, Once uses blazing-fast encryption to transparently mask the integer, and prepends your chosen prefix.
-
As a standard bigint in your database:
98770186085072901 -
In your Elixir App / JSON API:
"usr_4Tnk9-GqJ-s"
Your database gets perfectly optimized sequential writes. Your API consumers get unpredictable, Stripe-style, self-documenting strings.
Fully Composable: Pick What You Need
The best part about Once is that these features are fully composable and optional. You can tailor the ID generation to your exact needs:
-
Just want unique IDs? Use the defaults. You get 64-bit integers that are incredibly fast to generate and index in your database, and url64-encoded strings like
"AV7m9gAAAAU"in Elixir. -
Need chronological sorting? Change to
nonce_type: :sortable. You get a Snowflake-like ID where the first 42 bits are a timestamp, meaningORDER BY idworks perfectly. -
Want masked IDs? Just add
mask: true. -
Need true unpredictability at rest? If you don't want to use masking and genuinely need UUIDv4-style randomness stored in your database, switch to
nonce_type: :encrypted. -
Want Prefixes? Just add
prefix: "req_"to any of the above. -
Want to persist the prefix? Use a binary database format like
db_format: :hexand addpersist_prefix: true. -
Want a different API format like integers? Use
ex_format: :unsignedto render IDs like"usr_98770186085072901"
You can combine all of these as you see fit, and automagically generate distributed IDs with just the features that you need and nothing more.
Why not UUIDv7?
While UUIDv7 solves the index fragmentation problem, it's not as flexible as Once:
- Storage: UUIDv7 is still a bloated 128-bit identifier. Once cuts that in half (64-bit).
- API UX: UUIDv7 still looks like a massive, unreadable string (018e4b...). Once gives you Stripe-style usr_... prefixes.
- Business Leakage/Security: UUIDv7 visibly leaks the exact millisecond a record was created. Once uses masking to hide the underlying counter/timestamp entirely.
The 2-Minute Phoenix Quickstart
Here is how easy it is to drop into a Phoenix project.
1. Add the dependencies to mix.exs:
def deps do
[
{:once, "~> 1.2"}
]
end
2. Initialize the generator in your application.ex:
# Create a unique Machine ID for this node (supports up to 512 nodes in a cluster)
machine_id = NoNoncense.MachineId.id!(node_list: [Node.self()])
# Initialize a NoNoncense instance for Once with a secret key for masking
# (Pull this from your environment in production!)
secret_key = System.get_env("ONCE_SECRET_KEY", "my supersecret dev key")
NoNoncense.init(name: Once, machine_id: machine_id, base_key: secret_key)
Note that NoNoncense is not a GenServer; it keeps its write-once-read-often state in :persistent_term.
3. Define your Ecto Schema:
Just set Once as your primary key, give it a prefix, and turn on masking.
defmodule MyApp.Accounts.User do
use Ecto.Schema
@primary_key {:id, Once, autogenerate: true, prefix: "usr_", mask: true}
schema "users" do
field :email, :string
timestamps()
end
end
That’s it. You now have locally unique (within your app or domain), Stripe-prefixed, cryptographically masked 64-bit IDs that won't destroy your database indexes.
If you’re tired of the UUID performance tax or just want a better developer experience for your API consumers, give Once a try on your next project.
Check out the full documentation and advanced configuration options on HexDocs for Once and NoNoncense.
Top comments (0)