UUIDv7 in Java
Time-Ordered IDs for a Modern World
UUIDs are the dark matter of modern software. You rarely look at them directly, but they hold the distributed universe together. They are the easiest way to create a unique identifier, which is why they quietly power so many systems.
Table of Contents
UUIDs are Everywhere
Their appeal is obvious:
Easy to generate:
No centralized coordination needed.Collision resistant:
Generate as many as you want on your machine, others, or even across the globe in another data center.Safe to expose:
They (usually) don’t reveal how many rows you have in your database and aren’t guessable.Works in every stack:
From programming languages to databases to cloud providers, almost anything speaks UUID.
You have almost certainly used them before, as database primary keys, API identifiers, or distributed IDs that don’t need coordination between nodes.
For us as Java developers, a globally unique identifier is just a simple UUID.randomUUID() call away to generate a UUIDv4.
But as we look more closely, cracks start to appear. While standard UUIDs are great at being unique, they are terrible at being ordered. And in the world of databases and more, order matters.
Before we look at how UUIDv7 addresses these problems and how to use it in Java (since the JDK does not support it yet), let’s review what UUIDs are and why the current default in the JDK is not ideal.
What are UUIDs anyway?
Defined by the Internet Engineering Task Force in RFC 9562 (which updates RFC 4122), a Universally Unique Identifier (UUID) is a 128-bit identifier with a defined layout that guarantees uniqueness across “space and time.”
That’s quite a strong guarantee, but mathematically speaking, UUIDs aren’t guaranteed to be unique forever. For all practical purposes, however, the collision probability is so tiny that they are effectively unique.
For example, Java’s implementation is quite robust, but still relies on the underlying OS’s ability to generate entropy.
UUIDs follow specific rules to…
- ensure UUIDs generated by different systems won’t collide.
- allow different UUID versions and variants to coexist while remaining interoperable.
- make them parsable, so they can be inspected and reasoned about.
Every UUID follows the same structure of 5 groups:
041f71f7-42bd-4f7c-a6de-22c9b4ffcc0a
(1) (2) (3) (4) (5)The canonical string format (8-4-4-4-12) originates from UUIDv1, where each group represents a different type of data used to create the value. Today, it’s simply the standard textual representation for UUIDs.
All versions follow a specific layout with two fixed parts:
The Version:
How the UUID was generated.The Variant:
What “dialect” of UUID it is.
These two aspects are represented by the first digit of groups 3 and 4, respectively.
Everything else is the version payload, and what that payload represents is defined by the version.
041f71f7-42bd-4f7c-a6de-22c9b4ffcc0a
^ ^
| |
Version |
VariantThe Version
The first digit of the third group indicates the version. In the string form it looks like the first hex digit, but technically it’s the high bits of that field.
The most common versions are:
Version 1:
Time plus node identifier (typically the MAC Address). A privacy nightmare, but sortable.Version 4:
Pure randomness (The Java default)Version 7:
Unix Timestamp plus randomness (The new Hotness)
You might notice a few missing numbers in the list. They exist, but generally aren’t used for many use-cases for specific reasons:
Version 2 (DCE Security):
Rare, legacy, low-entropy format embedding POSIX UIDs/GIDs. Now essentially obsolete.Version 3 & 5 (Name-based):
Both are deterministic, as in if we feed them the same input string, we get the same UUID. This is excellent for idempotency or tracking external entities, but because they are generated via hashing (MD5 or SHA-1), the resulting bits are random.Version 6 (Reordered UUIDv1):
This was the prototype for UUIDv7. It takes the old format (which used a weird timestamp starting in 1582) and reorders the bits so they sort correctly. For new applications, UUIDv7 is preferred because it uses the standard Unix Epoch we all know and love.
The Variant
The first digit of the fourth group indicates the UUID variant, or “dialect.”
While the version shows how the UUID was generated, the variant tells computers how to interpret the 128 bits.
The need for multiple variants stems from the early 80s/90s, when everyone invented their own 128/bit identifier. They were similar, though not identical.
IETF standardization couldn’t just invalidate all existing systems. Instead, they carved out 1-3 bits in the 4th group to serve as a switch that tells parsers which system created the ID.
The variant field occupies the top bits of the 4th group, leading to the following common values:
Variant 8, 9, a, or b:
Standard RFC 4122 variant, and what we most likely encounter in Java.Variant c or d:
Reserved for Microsoft backward compatibilityVariant 0-7:
Reserved for older, legacy NCS systems
Why Java’s “Standard” UUIDs (v4) Don’t Cut It Anymore
Most Java applications default to UUIDv4. It’s purely random, which is great for uniqueness. However, it is disastrous for database performance and other use cases.
The Primary Key Problem
Most relational databases use B-Tree structures for primary keys, and B-Trees crave order. They are optimized for appending data to the “end” of the index, not random inserts.
That makes random UUIDs the B-Tree’s natural enemy. Because UUIDv4 is uniformly random, every insert could end up anywhere in the tree. This forces the database to load random pages from disk into memory to insert the new key, which often causes page splits. New records are scattered throughout the index, making the database perform expensive page splits and reducing cache efficiency.
Databases don’t store data row-by-row. Instead, they store data in fixed-size blocks called “pages” (typically 8KB or 16KB). A B-Tree index is effectively a hierarchy of these pages.
Imagine your database index as a bookshelf, and each “page” is a single shelf that can hold exactly 10 books.
The rule of the library is: Books must stay sorted alphabetically at all times, and if a shelf is full, we need a new shelf.
As a random UUID might be added to any shelf, not just at the end of the newest one, all the “books” need to be moved around.
This approach might be fine for small apps, but under load, it creates a massive performance penalty.
Cache locality dies:
Constant jumps around disk pages.Write amplification increases:
Splitting pages requires writing more data than necessary.Fragmentation:
Indexes become sparse and bloated.
In summary, random primary keys make your database spend more effort storing data instead of focusing on retrieving it efficiently.
The Missing Narrative
UUIDv4 guarantees uniqueness, but it tells us nothing about when something was created.
If we look at 041f71f7-42bd-4f7c-a6de-22c9b4ffcc0a, we cannot tell if the record was created 5 seconds ago or 5 years ago, since it does not include any time information.
That creates multiple issues:
No natural order:
We can’t simply sort entities by UUID to get creation order.The “Cursor” Problem:
Pagination breaks because there is no concept of a “cursor” (i.e., “give me items after UUID X”).The “Timestamp” Redundancy:
We are effectively forced to add acreatedAttimestamp somewhere to make sense of our data, because the UUID alone doesn’t provide enough context.Forensics is harder:
Distributed systems often use trace/correlation IDs. With pure randomness, we cannot look at a list of failures and immediately see the timeline of events. We’re forced to perform expensive JOINs or cross-reference separate timestamp columns just to reconstruct the “crime scene.”
By splitting “Identity” (the UUID) from “Time” (the creation date), we disconnect two properties that, in 99% of business applications, belong together.
We’ve already learned that UUIDs combine different sources of randomness, so why not use one that’s time-ordered and lexicographically sortable?
Enter UUIDv7
UUIDv7 is a standardized answer to these problems.
Released in RFC 9562 (May 2024), it keeps the core benefits of uniqueness, distributed generation without coordination, but uses a Unix timestamp for its internal layout.
Conceptually, UUIDv7 consists of:
┌─────────── 48 bits ───────────┐
│ Unix epoch timestamp (millis) │
│ (most significant) │
└───────────────────────────────┘
┌─────────── 4 bits ────────────┐
│ Version │
│ = 7 (0111) │
└───────────────────────────────┘
┌─────────── 12 bits ───────────┐
│ Sub-ms Sequence Counter │
│ (monotonicity) │
└───────────────────────────────┘
┌─────────── 2 bits ────────────┐
│ Variant │
│ = 2 (10) │
└───────────────────────────────┘
┌─────────── 62 bits ───────────┐
│ Randomness │
│ (entropy against collisions) │
└───────────────────────────────┘This layout solves the sorting problem naturally. Because the timestamp is in the most significant bits (the “front”), comparing two UUIDv7s as bytes effectively compares their creation times.
Insert them into a database, and they are appended to the end of the B-Tree. Generate them across nodes, and they still line up (approximately) by time without any additional coordination.
History Lesson: Why did this take so long?
Drafts for UUIDv7 appeared around 2020 and 2021, but the idea of a database-friendly unique identifier has been around for a while. Tech giants have been solving the apparent problems with custom implementations for years:
Twitter Snowflake (2010):
A 64-bit ID with a timestamp at the front.ULID (2016):
A 128-bit sortable identifier that heavily inspired UUIDv7.
UUIDv7 effectively standardizes ULID concepts within the RFC UUID spec.
Java, however, even with its faster pace in recent years, hasn’t caught up yet, and only provides UUIDs up to v4.
There are several libraries already available that could do the job, but we’re here to learn something! So, it makes sense to try implementing it ourselves.
Understanding the UUIDv7 Layout Constraints
To start implementing UUIDv7, we don’t need to memorize the entire specification first.
The tl;dr version is that we need to respect three design constraints, as it’s not enough to just jam a timestamp into a byte array and add some random data. We have to follow the rules that ensure both order and uniqueness.
1. Timestamp First
The most critical design choice of UUIDv7 is placing the 48-bit Unix Timestamp in the Most Significant Bits, the very beginning of the binary data.
This differs from UUIDv1, which confusingly splits its timestamp into three separate chunks (Low, Mid, High) and scrambles them, meaning even with a timestamp in the data, sorting by byte-comparison doesn’t work.
2. Sub-Millisecond Sequence
This is the hardest part of the implementation.
The first 48 bits encode the timestamp in milliseconds. But today’s computers are fast! A modern CPU can generate thousands of UUIDs within a single millisecond.
If we generated 10 IDs in the same millisecond and only used the timestamp, they would all sort randomly based on the tail bits. We would lose our insertion order for that millisecond.
To fix this, UUIDv7 allocates a specific block of bits immediately after the version as a counter linked to the current millisecond:
Time moves forward:
We reset the counter.Time stays the same:
We increment the counter.Time moves backward (Clock drift):
We increment the counter and refuse to update the timestamp, ensuring the ID remains monotonic even if the system clock glitches.
This simple addition to the algorithm effectively extends our precision beyond the millisecond level without requiring a nanosecond-precise clock, which is often unreliable across OSs.
3. Randomness is the “Caboose”
After we satisfy the Timestamp (order), the Version/Variant (compliance), and the Sequence (monotonicity), we fill the remaining ~62 bits with pure noise.
This ensures that even if two different machines generate UUIDs at the exact same millisecond with the exact same sequence counter, they will practically never collide.
Time defines structure and order.
Randomness protects against collisions.
Implementing UUIDv7
Implementing UUIDv7 is not a problem of complex math but of concurrency and bit-packing. We need to ensure that multiple threads generate collision-free UUIDs so they remain unique and ordered.
This section walks through a clean implementation strategy using modern Java and explains why each piece is chosen over more obvious alternatives.
Choosing a Time Source
There are multiple ways to get the Unix epoch time in Java.
The 2 most obvious ways are:
long epochTime1 = System.currentTimeMillis();
long epochTime2 = Instant.now().toEpochMilli();Both deliver the required value, but hard-coding the source makes the generator harder to test and verify, as we can’t control the timing and simulate clock skew.
Instead, let’s give our implementation a Clock to use:
import java.time.Clock;
public final class UUIDv7 {
private final Clock clock;
public UUIDv7(Clock clock) {
this.clock = clock;
}
}Our implementation uses actual Unix epoch time as required by v7, and can be tested by injecting a Clock.fixed(...) or a custom implementation.
That’s a good start! Next, the shared state.
Lock-Free Shared State
For the sequence counter, we need shared state between generations to ensure correctness.
- The sequence counter that resets at millis resolution
- The last timestamp used, so we know when to reset
We could store two values and use a synchronized block to update them:
synchronized (this) {
// update timestamp and sequence
}Even though this “works”, it comes with multiple downsides:
- A global lock on
this - Contention under load
- Unnecessary thread scheduling overhead
Instead, let’s use a single AtomicLong, as it allows us to be lock-free and extremely fast.
We pack both the timestamp and the sequence into a single 64-bit long for an atomic Compare-And-Swap (CAS) operation to prevent any race conditions:
import java.time.Clock;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
public final class UUIDv7 {
private final AtomicLong state = new AtomicLong(0L);
private final Clock clock;
public UUIDv7(Clock clock) {
this.clock = clock;
}
}Even though the timestamp consumes only 48 bits (and could be trimmed further), we use only 12 bits for the sequence counter, since the UUID itself uses only 12 bits.
In practice, that means only 4096 UUIDs can be generated per millisecond. This is a deliberate design trade-off in our implementation to keep it simple. To mitigate, we stall generation if that happens until the counter resets naturally.
The State Transition Algorithm
At a high level, the v7 generation always follows the same decision tree:
- Read the current epoch millisecond.
- Read the previous timestamp and sequence counter value.
- Calculate the next state.
- Atomically update it.
- Encode the values into a UUID.
So let’s implement a generate method:
public UUID generate() {
while (true) {
// STEP 1: Read current generator state.
// Format: [52-bit timestamp][12-bit sequence])
long currentState = state.get();
// Unsigned right shift extracts upper 52 bits
long prevTimestamp = currentState >>> 12; // Previous timestamp (64-12 bits)
// Mask extracts lower 12 bits
int prevSequence = (int) (currentState & 0xFFFL);
// STEP 2: Read current epoch value
long timestamp = this.clock.millis();
// STEP 3: Calculate next state
long sequence;
if (timestamp > prevTimestamp) {
// SCENARIO 1: Clock advanced to a new millisecond.
// Reset sequence counter to 0.
sequence = 0;
}
else {
// SCENARIO 2: Either same millisecond OR clock moved backward (NTP adjustment).
// In both cases, preserve monotonicity by:
// 1. Using the previous timestamp (ignore backward movement)
// 2. Incrementing the sequence counter
timestamp = prevTimestamp;
sequence = prevSequence + 1;
// Boundary Check: Sequence counter is only 12 bits (0-4095)
if (sequence > 0xFFF) {
// If we've generated 4096 UUIDs in the same millisecond,
// we use Spin-wait until the clock advances.
// Thread.onSpinWait() hints to the CPU that we're in a busy-wait loop
while (clock.millis() <= prevTimestamp) {
Thread.onSpinWait();
}
// Retry with the new timestamp
continue;
}
}
// STEP 4: Pack the new state.
// Format: [52-bit timestamp][12-bit sequence])
long nextState = (timestamp << 12) | sequence;
// STEP 5: Update State atomically.
// compareAndSet ensures that if another thread modified state between
// our read and this update, we'll retry the entire operation.
if (state.compareAndSet(currentState, nextState)) {
// STEP 5: Encode values into UUID
return buildUUID(timestamp, sequence);
}
// At this point CAS failed.
// Retry with the new state
}
}Bit-Packing
Once we have a valid next timestamp and sequence, we need to mold them into the UUIDv7 layout.
Remember the layout:
High Long (64 bits):
- top 48 bits: Unix Timestamp
- next 4 bits: Version (0111 = 7)
- lowest 12 bits: Sub-millisecond sequence
Low Long (64 bits):
- top 2 bits: Variant (
10) - remaining 62 bits: Randomness (entropy against collisions)
- top 2 bits: Variant (
Here’s another commented implementation:
import java.security.SecureRandom;
public final class UUIDv7 {
// ...
private final SecureRandom random = new SecureRandom();
// ...
private UUID buildUUID(long timestamp, long sequence) {
// STEP 1: HIGH BITS CONSTRUCTION (Most Significant Bits)
// Start with timestamp in the leftmost 48 bits
// Mask ensures we only use 48 bits: 0xFFFFFFFFFFFF = 48 set bits
// Left shift by 16 to make room for version (4 bits) + sequence (12 bits)
long msb = (timestamp & 0xFFFFFFFFFFFFL) << 16;
// OR in the version field: 7 in binary is 0111
// 0x7000 = 0111 0000 0000 0000 in binary (version 7 in correct position)
msb |= 0x7000L;
// OR in the sequence counter in the lowest 12 bits
// Mask ensures sequence fits in 12 bits: 0xFFF = 0000 1111 1111 1111
msb |= (sequence & 0xFFFL);
// STEP 2: LOW BITS CONSTRUCTION (Least Significant Bits)
long randomBits = random.nextLong();
// Set variant bits: must be 10 (binary) per RFC 9562
// 0x3FFFFFFFFFFFFFFF clears top 2 bits: 00111111...
// 0x8000000000000000 sets top bit to 1: 10000000...
// Result: 10xxxxxx... where x = random bits
long lsb = (randomBits & 0x3FFFFFFFFFFFFFFFL) | 0x8000000000000000L;
// STEP 3: Construct the UUID from the two 64-bit longs
return new UUID(msb, lsb);
}
}I’m using
SecureRandomhere because it’s the safest default if UUIDs might leak into URLs or logs. If we only want collision resistance and not unpredictability, a fastRandomGeneratoris usually enough and significantly cheaper.
In the formal RFC 9562, the sequence bits are actually part of the rand_a field immediately following the version.
Our code above effectively implements this by placing the sequence in the 12 bits of the High long that remain after the timestamp and version.
Time dominates ordering, version and variant are immutable, randomness comes last.
Putting everything together
Now that we have all the parts, we still need to put them together in a thread-safe way.
Even though the CAS operation for the state ensures that for the UUIDv7 instance, what about multiple instances?
If we created multiple UUIDv7 instances, they would still produce valid values, but we lose process-wide monotonicity.
UUID uniqueness would still hold, but ordering guarantees would become instance-local instead of process-wide.
So we need a singleton.
The constructor accepting the Clock must be made package-private, making it available for testing, but UUIDs should be generated via a static method:
import java.security.SecureRandom;
import java.time.Clock;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
public final class UUIDv7 {
private final AtomicLong state = new AtomicLong(0L);
private final SecureRandom random = new SecureRandom();
private final Clock clock;
// NEW: Shared singleton
private final static UUIDv7 SHARED = new UUIDv7(Clock.systemUTC());
// CHANGED: package-private constructor so it's available for testing
UUIDv7(Clock clock) {
this.clock = clock;
}
// CHANGED: package-private access only for testing
UUID generate() {
// ...
}
private UUID buildUUID(long timestamp, long sequence) {
// ...
}
public static UUID randomUUID() {
return SHARED.generate();
}
}Now we have an implementation that is easy to test and provides a safe API.
Proving It Works
The generated UUIDs look fine and sort correctly, but we need to actually prove it’s working, especially what is not working. So let’s create some JUnit 5 tests!
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
class UUIDv7Tests {
@Test
void versionAndVariantAreCorrect() {
UUID result = UUIDv7.randomUUID();
assertEquals(7, result.version(), "must be UUIDv7");
assertEquals(2, result.variant(), "must be IETF variant");
}
@Test
void idsAreStrictlyIncreasingInSingleThread() {
MutableClock clock = new MutableClock(1_700_000_000_000L);
UUIDv7 generator = new UUIDv7(clock);
UUID prev = generator.generate();
for (int i = 0; i < 10_000; i++) {
UUID next = generator.generate();
assertTrue(compareUnsignedLex(prev, next) < 0, "not monotonic: prev=" + prev + ", next=" + next);
prev = next;
// occasionally advance time so both paths are exercised
if ((i % 257) == 0) {
clock.addMillis(1);
}
}
}
@Test
void clockRollbackDoesNotBreakMonotonicity() {
MutableClock clock = new MutableClock(10_000L);
UUIDv7 generator = new UUIDv7(clock);
UUID a = generator.generate();
clock.addMillis(1);
UUID b = generator.generate();
// simulate wall-clock going backwards
clock.setMillis(9_000L);
UUID c = generator.generate();
assertTrue(compareUnsignedLex(a, b) < 0);
assertTrue(compareUnsignedLex(b, c) < 0, "must not go backward when clock regresses");
}
@Test
void concurrentGenerationProducesUniqueIds() throws Exception {
int threads = 6;
int perThread = 5_000;
Set<UUID> all = ConcurrentHashMap.<UUID>newKeySet(threads * perThread);
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
for (int t = 0; t < threads; t++) {
pool.submit(() -> {
start.await();
for (int i = 0; i < perThread; i++) {
all.add(UUIDv7.randomUUID());
}
return null;
});
}
start.countDown();
pool.shutdown();
assertTrue(pool.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(threads * perThread, all.size(), "no duplicates allowed");
}
// HELPERS
/**
* Compares two UUIDs lexicographically using unsigned comparison.
* <p>
* Java's {@link UUID#compareTo(UUID)} uses signed long comparison,
* which doesn't match the lexicographic byte ordering required by UUIDv7.
*/
private static int compareUnsignedLex(UUID a, UUID b) {
int msb = Long.compareUnsigned(a.getMostSignificantBits(), b.getMostSignificantBits());
if (msb != 0) {
return msb;
}
return Long.compareUnsigned(a.getLeastSignificantBits(), b.getLeastSignificantBits());
}
/**
* Mutable Clock variant that allows setting the current millis.
*/
private static final class MutableClock extends Clock {
private volatile long millis;
MutableClock(long initialMillis) { this.millis = initialMillis; }
void setMillis(long ms) { this.millis = ms; }
void addMillis(long delta) { this.millis += delta; }
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
@Override public long millis() { return millis; }
@Override public Instant instant() { return Instant.ofEpochMilli(millis); }
}
}In production code, I’d add more tests than shown here:
- Sequence-overflow test (4096 IDs in one millisecond) to verify that the generator backpressures correctly
- Stress tests for high concurrency
- Field-level bit assertions that validate the exact placement of timestamp/version/variant bits according to the RFC
For this article, the four tests above cover the most important behavioral guarantees without turning it into a guide on testing UUIDs. But you can check out the GitHub repository for more tests.
The End of the Random Era
For years, UUIDs were magic black boxes. We accepted the trade-off that “global uniqueness” meant things like “database fragmentation.” We accepted that if we wanted to know when a record was created, we had to store the timestamp twice.
UUIDv7 proves that we don’t have to make those compromises.
By respecting the constraints of time and ordering, we get the best of both worlds:
The distributed scale of UUIDs:
We can still generate them anywhere, without coordination.The performance of numeric sequences:
Our database indexes remain compact, unfragmented, and fast.The needed Context:
UUIDs carry their own history, simplifying debugging and data recovery.
Perhaps the most important takeaway is that we shouldn’t wait for the JDK.
As we saw in the implementation, a robust, thread-safe, monotonically increasing ID generator is less than 50 lines of Java. It doesn’t require deep magic or unsafe code, it just requires understanding how to manage state atomically.
Whether you drop this class into your project today, wait for an official java.util.UUIDv7 in a future release, or use one of the available libraries like java-uuid-generator, the concept remains the same:
Entropy is good for uniqueness, but order is better for persistence.
Resources
GitHub repository
https://github.com/belief-driven-design/blog-uuidv7RFC 9562 – UUIDs and GUIDs
https://www.rfc-editor.org/rfc/rfc9562.htmlIETF UUID Draft History
Background and design discussion leading up to RFC 9562.
https://datatracker.ietf.org/wg/uuid/documents/java-uuid-generator (JUG)
https://github.com/cowtowncoder/java-uuid-generator
