Elixir’s Ecto library supports UUID primary keys out of the box via the :binary_id type, storing 16 bytes in the database instead of a 36-character string. The default generator produces UUID v4. With the uuid_v7 Hex package, you can switch to UUID v7 for time-sortable, B-tree-friendly primary keys. The Wikipedia UUID v7 section summarizes why the timestamp prefix matters at scale, and Ecto.Schema documentation covers the @primary_key and @foreign_key_type module attributes used throughout this guide.
Setting up UUID primary keys in Ecto
The simplest way to enable UUIDs globally is to set @primary_key and @foreign_key_type at the top of each schema, or centralize them in a use MyApp.Schema macro:
# lib/my_app/schema.ex
defmodule MyApp.Schema do
defmacro __using__(_) do
quote do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
end
end
end
# lib/my_app/accounts/user.ex
defmodule MyApp.Accounts.User do
use MyApp.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
timestamps()
end
end
The corresponding migration:
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true, null: false
add :email, :string, null: false
timestamps()
end
end
end
Switching to UUID v7
UUID v4’s random bit pattern causes B-tree index fragmentation at scale — the same problem documented in the PostgreSQL 18 UUID functions documentation. UUID v7’s millisecond timestamp prefix eliminates this by inserting new rows at the end of the index.
Install the uuid_v7 package:
# mix.exs
defp deps do
[
{:uuid_v7, "~> 0.2"}
]
end
Then use a custom autogenerate in your schema macro:
defmodule MyApp.Schema do
defmacro __using__(_) do
quote do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: {UUIDv7, :generate, []}}
@foreign_key_type :binary_id
end
end
end
UUIDv7.generate/0 returns a standard hyphenated UUID v7 string. Ecto encodes it as 16 bytes when inserting into a binary_id column. On PostgreSQL, this maps directly to the native uuid column type.
UUID v7 with PostgreSQL 18
If your database is PostgreSQL 18+, you can use the built-in uuidv7() function as the column default instead of application-level generation:
defmodule MyApp.Repo.Migrations.CreateOrders do
use Ecto.Migration
def change do
create table(:orders, primary_key: false) do
add :id, :binary_id,
primary_key: true,
null: false,
default: fragment("uuidv7()")
add :user_id, references(:users, type: :binary_id), null: false
add :total_cents, :integer, null: false
timestamps()
end
end
end
This generates UUID v7 at the database layer. Application-layer generation (via uuid_v7) is still preferred when you need the ID before the insert completes — for example, when constructing related records in memory before a batch insert.
Querying by time range using the UUID v7 timestamp
Because UUID v7 encodes a Unix millisecond timestamp in the high bits, you can use the primary key for time-range pagination without a separate inserted_at index:
import Ecto.Query
# Orders created in the last 24 hours — uses primary key index
one_day_ago = DateTime.utc_now() |> DateTime.add(-86_400, :second)
min_uuid = UUIDv7.from_milliseconds(DateTime.to_unix(one_day_ago, :millisecond))
from(o in Order, where: o.id >= ^min_uuid, order_by: [asc: o.id])
|> Repo.all()
The UUIDv7.from_milliseconds/1 function constructs the minimum UUID v7 value for a given millisecond, enabling efficient range scans on the primary key index.
Phoenix LiveView considerations
In Phoenix LiveView, IDs are often used as DOM keys for efficient diffing. Use UUIDv7.generate/0 in server-side contexts — changeset callbacks, event handlers, or Ecto schema defaults. Never generate UUIDs inside a LiveView render/1 function, as re-renders would produce new values and break DOM identity.
# Good — generated once in handle_event
def handle_event("create_item", _params, socket) do
item = %Item{id: UUIDv7.generate(), name: "New item"}
{:noreply, assign(socket, :item, item)}
end
Related reading
- UUID in Databases — storage types, index behaviour, and migration patterns
- UUID v4 vs v7 — when to use each version
- UUID v7 Generator — generate RFC 9562 v7 values in your browser
- UUID Byte Layout — the 128-bit structure of a UUID v7
External references
- Ecto documentation — official Ecto library docs including schema, changeset, and query APIs
- uuid_v7 on Hex.pm — RFC 9562-compliant UUID v7 generator for Elixir
- Phoenix phx.gen.schema docs — generator options including —binary-id flag
- RFC 9562 — UUID specification — the standard defining UUID v4, v7, and all other versions
Frequently asked questions
How do I use UUID as a primary key in Ecto?
Add @primary_key {:id, :binary_id, autogenerate: true} to your Ecto schema. Then run migrations with create table(:users, primary_key: false) do; add :id, :binary_id, primary_key: true; end. By default Ecto generates UUID v4 via Ecto.UUID.generate/0. For UUID v7 use the uuid_v7 Hex package and a custom autogenerate hook.
Does Phoenix use UUIDs by default?
Not by default — Phoenix generators use integer primary keys unless you pass the --binary-id flag: mix phx.gen.schema User users --binary-id. This sets :binary_id as the primary key type across the schema and its migration. See the Phoenix phx.gen.schema docs for all generator options.
What is the difference between :uuid and :binary_id in Ecto?
:uuid stores the value as a 36-character hyphenated string. :binary_id stores it as 16 raw bytes, which is more space-efficient and the correct choice for UUID primary keys. PostgreSQL's native uuid type maps to :binary_id in Ecto.