UUUIDv7 or uUUIDv7 or microUUIDv7 for Elixir
Version 7 UUIDs with submicrosecond precision, bulk generation, and a boxed-struct Ecto type that skips the hex round-trip on inserts.
Normally UUIDv7 uses millisecond precision, which causes ordering issues
when generating UUIDs in bulk inside the same millisecond. This library
uses the 12-bit sub-millisecond field for added precision — at the cost
of trading 12 bits of randomness from rand_a (62 bits of rand_b
remain, still well above any cryptographic uniqueness threshold).
Requires Elixir 1.18+.
Usage
Basic UUID generation
# Hex string
UUIDv7.generate()
# => "018e90d8-06e8-7f9f-bfd7-6730ba98a51b"
# Raw 16-byte binary
UUIDv7.bingenerate()
# => <<1, 142, 144, 216, 6, 232, 127, 159, 191, 215, 103, 48, 186, 152, 165, 27>>
# For a specific datetime
{:ok, dt, _} = DateTime.from_iso8601("2024-01-01T00:00:00.000Z")
UUIDv7.generate_from_datetime(dt)
Bulk generation
bingenerate_many/1 / generate_many/1 produce strictly-ordered batches
much faster than calling bingenerate/0 in a loop — the system clock is
read once and all the random bytes come from a single
:crypto.strong_rand_bytes/1 call.
UUIDv7.bingenerate_many(1000)
# => [<<...>>, <<...>>, ...] # 1000 raw binaries, strictly ordered
UUIDv7.generate_many(1000)
# => ["018e...", "018e...", ...] # same as hex strings
For batches of n <= 4096 the 12-bit sub-millisecond field is
partitioned into n equal slots, with a crypto-strong jitter inside
each slot:
- strict ordering — slot
i+1's minimum is always above sloti's maximum - randomized positions inside each slot, so the 12-bit field
doesn't fall on predictable multiples of
step - no PRNG overhead — both
rand_b(62 bits) and the jitter (12 bits) come from the same crypto call
For n > 4096 the batch is auto-chunked across consecutive
milliseconds (each ms can hold at most 4096 strictly-ordered values):
uuids = UUIDv7.bingenerate_many(10_000)
length(uuids) # => 10_000
uuids == Enum.sort(uuids) # => true
This burns div(n - 1, 4096) "future" milliseconds; subsequent
bingenerate/0 calls landing in those same milliseconds still won't
collide thanks to the 62 random bits per UUID, but the batch reaches
ahead of the wall clock.
Benchmarks
From bench/bench.exs on Ryzen 7 7840HS (Elixir 1.19, JIT on):
| n | bingenerate/0 loop | bingenerate_many/1 | speedup |
|---|---|---|---|
| 10 | 7.95 μs | 1.22 μs | 6.5× |
| 100 | 75.40 μs | 2.97 μs | 25.4× |
| 1000 | 802.05 μs | 24.52 μs | 32.7× |
| 4096 | 3275 μs | 207.56 μs | 15.8× |
Boxed UUID struct + Ecto type
UUID is a struct that wraps the raw 16-byte binary instead of the
hex-encoded string. UUIDv7.Boxed is the matching Ecto type:
schema "users" do
@primary_key {:id, UUIDv7.Boxed, autogenerate: true}
...
end
Why use it? Ecto.UUID carries UUIDs as 36-char hex strings, which
forces a hex → binary decode on every Repo.insert/1 (because
Postgres stores them as 16-byte binaries). UUIDv7.Boxed keeps the raw
bytes in a %UUID{} struct and only formats to hex on to_string/1,
inspect/1, and JSON encoding — so the round-trip cost is paid once at
display time, not on every write.
Round-trip benchmark (autogenerate/0 + dump/1, per row):
| Type | ips | avg | memory |
|---|---|---|---|
UUIDv7.Boxed (struct) | 1.04 M | 0.96 μs | 272 B |
UUIDv7 (hex string) | 0.73 M | 1.37 μs | 358 B |
1.43× faster and 24% less memory per insert.
# Constructing
UUID.new("018e90d8-06e8-7f9f-bfd7-6730ba98a51b")
UUID.new(<<1, 142, 144, 216, 6, 232, 127, 159, 191, 215, 103, 48, 186, 152, 165, 27>>)
# Inspecting renders pasteable code — no import needed
inspect(uuid)
# => "UUID.new(\"018e90d8-06e8-7f9f-bfd7-6730ba98a51b\")"
# String/JSON encoding lazily formats to hex
to_string(uuid)
# => "018e90d8-06e8-7f9f-bfd7-6730ba98a51b"
# Bulk-generate boxed values
UUIDv7.Boxed.generate_many(1000)
# => [%UUID{}, %UUID{}, ...]
The UUID struct is version-agnostic — any 16-byte UUID can live
inside it. UUIDv7.Boxed is the v7 autogen integration.
Date / DateTime <-> UUID
min_uuid/1 returns a UUID with the given timestamp and all random bits
set to zero — useful for half-closed time-range queries on a UUIDv7
primary key. Accepts both Date (midnight UTC) and DateTime:
# WHERE uuid >= ^UUIDv7.min_uuid(~D[2024-01-01])
# AND uuid < ^UUIDv7.min_uuid(~D[2024-02-01])
UUIDv7.min_uuid(~D[2024-01-01])
# => "018cc251-f400-7000-8000-000000000000"
UUIDv7.min_uuid(~U[2024-01-01 12:30:00Z])
# => "018cca8d-0d00-7000-8000-000000000000"
Ranges are continuous with no gaps or overlaps — ideal for partition pruning when the table is range-partitioned on a UUIDv7 primary key.
The inverse extractors return UTC values:
UUIDv7.to_datetime("018ecb40-c457-73e6-a400-000398daddd9")
# => ~U[2024-04-11 03:43:23.223Z]
UUIDv7.to_date("018ecb40-c457-73e6-a400-000398daddd9")
# => ~D[2024-04-11]
Both accept the hex string form or the raw 16-byte binary.
Utility functions
# Extract the millisecond timestamp from a UUID
UUIDv7.extract_timestamp(uuid)
# => 1712807003223
# Encode/decode between hex string and 16-byte binary
raw = UUIDv7.decode("018e90d8-06e8-7f9f-bfd7-6730ba98a51b")
hex = UUIDv7.encode(raw)
Installation
def deps do
[
{:uuuidv7, "~> 0.3.0"}
]
end
Optional dependencies — all auto-detected:
ecto— required forUUIDv7andUUIDv7.BoxedEcto type integrationjason—UUIDgets aJason.Encoderimpl when Jason is present (otherwise the Elixir 1.18+ built-inJSONmodule'sJSON.Encoderimpl is used)
Running benchmarks
mix run bench/bench.exs