How to use UUID v7 on PostgreSQL with Ecto in Elixir
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!