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

External references

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.