Article cover image

How to use UUID v7 on PostgreSQL with Ecto in Elixir

Author profile image
Aitor Alonso

Dec 27, 2024

4 min read

Since UUID v7 came officially out, I've always tried to use it for primary keys on all new projects and databases. However, due to it being so new, it's not supported by default on a lot of databases and libraries.

As I'm working recently, both professionally and personally, with Elixir and PostgreSQL, I'll show you how do I use UUID v7 on PostgreSQL with Ecto in Elixir. Fully integrated with Ecto library and without the need for any external library. You will forget that you are using UUID v7 under the hood, because once configured,everything will be like working with the good old UUID v4.

Let's get started!

Defining our UUIDv7 Ecto.Type module

In order to fully integrate UUID v7 with Ecto, we need to define a custom Ecto.Type module. This module will be responsible for converting the UUID v7 to a binary format and vice versa. In my case, I created a file under lib/my_app/uuid.ex as follows:

defmodule MyAPP.UUID do
  @moduledoc """
  A UUID v7 implementation and `Ecto.Type` for Elixir.

  Based on the RFC for the version 7 UUID: [RFC 9562](https://datatracker.ietf.org/doc/rfc9562/).

  Borrowed from https://github.com/martinthenth/uuidv7

  ## Usage

  In the database schema, change primary key attribute from `:binary_id` to `MyApp.UUID`:

  def MyApp.Schemas.User do
  @primary_key {:id, MyApp.UUID, autogenerate: true}
  end
  """

use Ecto.Type

@typedoc """
A hex-encoded UUID string.
"""
@type t :: <<\_::288>>

@typedoc """
A raw binary representation of a UUID.
"""
@type raw :: <<\_::128>>

@doc false
@spec type() :: :uuid
def type, do: :uuid

defdelegate cast(value), to: Ecto.UUID

defdelegate cast!(value),
to: Ecto.UUID

defdelegate dump(value), to: Ecto.UUID

defdelegate dump!(value), to: Ecto.UUID

defdelegate load(value), to: Ecto.UUID

defdelegate load!(value), to: Ecto.UUID

@doc false
@spec autogenerate() :: t()
def autogenerate, do: generate()

@doc """
Generates a version 7 UUID.
"""
@spec generate() :: t()
def generate, do: cast!(bingenerate())

@doc """
Generates a version 7 UUID based on the timestamp (ms).
"""
@spec generate(non_neg_integer()) :: t()
def generate(milliseconds), do: milliseconds |> bingenerate() |> cast!()

@doc """
Generates a version 7 UUID in the binary format.
"""
@spec bingenerate() :: raw()
def bingenerate, do: :millisecond |> System.system_time() |> bingenerate()

@doc """
Generates a version 7 UUID in the binary format based on the timestamp (ms).
"""
@spec bingenerate(non*neg_integer()) :: raw()
def bingenerate(milliseconds) do
  <<u1::12, u2::62, *::6>> = :crypto.strong_rand_bytes(10)
  <<milliseconds::48, 7::4, u1::12, 2::2, u2::62>>
end

@doc """
Extracts the timestamp (ms) from the version 7 UUID.
"""
@spec timestamp(t() | raw()) :: non*neg_integer()
def timestamp(<<milliseconds::48, 7::4, *::76>>), do: milliseconds
def timestamp(<<\_::288>> = uuid), do: uuid |> dump!() |> timestamp()
end

Now that we defined our Ecto.Type module, we can use it on our database schemas.

Using our custom Ecto.Type on our Ecto schemas

Let's say we have a User schema with a primary key of type UUIDv7. We can define it as follows:

defmodule MyApp.Schemas.User do
  @moduledoc """
  User schema.
  """

  use Ecto.Schema

  @type t() :: %__MODULE__{
          id: MyApp.UUID.t(),
          email: String.t(),
          hashed_password: String.t(),
        }

  @primary_key {:id, MyApp.UUID, autogenerate: true}
  @foreign_key_type MyApp.UUID
  schema "users" do
    field :email, :string
    field :hashed_password, :string, redact: true

    timestamps(type: :utc_datetime)
  end
end

Nice! Now, let's take a look at how to configure the repository to automatically use our custom UUIDv7 Ecto.Type module on our database migrations.

Configuring the repository and database migrations

We need to update our repository configuration, usually under config/config.exs, to use our custom UUIDv7 Ecto.Type module for autogenerated primary keys and IDs.

config :my_app,
  ecto_repos: [MyApp.Repo],
  generators: [
    timestamp_type: :utc_datetime,
    binary_id: {MyApp.UUID, :generate, []},
    migration_primary_key: {:id, :uuid, autogenerate: true},
    migration_timestamps: [type: :utc_datetime]
  ]

Then, our autogenerated database migrations created with mix ecto.gen.migration should look like this:

defmodule MyApp.Repo.Migrations.CreateUsersTables do
  use Ecto.Migration

  def change do
    execute "CREATE EXTENSION IF NOT EXISTS citext", ""

    create table(:users, primary_key: false) do
      add :id, :uuid, primary_key: true
      add :email, :citext, null: false

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])
  end
end

And that's it! Now, you can use UUID v7 on PostgreSQL with Ecto in Elixir without the need for any external library.

Conclusion

As you can see, it's very easy to use UUID v7 on PostgreSQL with Ecto in Elixir. You can forget that you are using UUID v7 under the hood, because once configured, everything will be like working with the good old UUID v4. Also, not needing any external library is a huge plus, as you are in control of the implementation and you can easily understand how it works under the hood. You remove potential security risks, potential deps problems, and you can easily integrate it with other libraries and tools that expect UUID v4.

I hope this helps you to start using UUID v7 on PostgreSQL with Ecto in Elixir. Happy coding!

I hope my article has helped you, or at least, that you have enjoyed reading it. I do this for fun and I don't need money to keep the blog running. However, if you'd like to show your gratitude, you can pay for my next coffee with a one-time donation of just $1.00. Thanks!

No by AICC-BY 4.0

© Copyright 2025 Aitor Alonso.

Articles licensed under CC-BY 4.0