UUIDv7 vs UUIDv4 vs ULID: Which ID to Use in Java

UUIDv7 vs UUIDv4 vs ULID in Java: compare layout, sort order, timestamp exposure and measured insert speed, then pick the right ID for your primary keys.

Bit layout of UUIDv4 (random with version and variant bits), UUIDv7 (48-bit Unix millisecond time first) and ULID (48-bit time plus 80 random bits, 26 Crockford Base32 characters)

Auto-increment IDs work well until an application needs to know an ID before the insert, merge data from two databases, or put IDs in public URLs. The usual answer is to let Java create the ID with UUID.randomUUID(). That solves those problems, but it quietly makes every insert into the primary key index more expensive. UUIDv7 and ULID fix that cost by putting a timestamp at the start of the ID, and that timestamp brings a trade-off of its own.

In short: use UUIDv7 for primary keys, UUIDv4 for IDs that must reveal nothing, and ULID only when people read or type the ID.

In this tutorial, we’ll follow that story step by step: the problem, the cost of random IDs, the timestamp fix and its catch, and finally which ID to pick. Every claim comes with a test or a measurement.

1. The Problem With Auto-Increment IDs

Let’s say we’re building an online shop, and every order gets an auto-increment ID from the database. This works fine at first, but three situations break it:

  • We want to publish an “order created” event, or attach order lines, before the insert commits. We can’t, because the database assigns the ID during the insert.
  • We split the shop into two services with their own databases. Both start counting at 1, so when we copy their orders into one reporting database, two different orders are both 1042.
  • A customer sees /orders/1042 in a URL, tries /orders/1041, and finds someone else’s order. A competitor who places two orders a day apart can also tell how many orders we took in between.

The fix for all three is the same: let the application create the ID itself, from a value so large and random that no other server will ever produce it. Java has done this since Java 5:

UUID orderId = UUID.randomUUID();

This is a UUIDv4, a version 4 UUID: 128 bits, 122 of them random. Our code has the ID before the database sees the row, every server can create IDs without coordination, and the next ID can’t be guessed. So far, so good.

2. Why Random UUIDv4 Keys Slow Down Inserts

The cost shows up inside the database. A primary key index is usually a B-tree: the keys are kept sorted and stored in fixed-size blocks called pages. To insert a key, the database finds the page where the key belongs, loads it into memory if it isn’t there already, and writes the key into it.

With auto-increment IDs, every new key is the largest so far, so it always goes on the last page. That page is already in memory from the previous insert, and the index grows neatly at its right edge.

With UUIDv4, every new key lands on a random page. Once the index is larger than the database’s memory, most of those pages have to be read from disk first, and full pages have to be split to make room. The more rows we have, the more of the index each insert touches.

To see how much this costs, we inserted 1,000,000 orders into an H2 2.5.252 file database with this table:

-- jdbc:h2:file:<temp-dir>/orders;CACHE_SIZE=16384
CREATE TABLE orders (id UUID PRIMARY KEY, total INT NOT NULL)

The comment shows the connection URL. CACHE_SIZE=16384 limits H2’s page cache to 16 MB, much smaller than the final index. This way, page loading shows up in the timing, as in a large production table.

The InsertBenchmark class in the example project inserts the rows on one thread, in batches of 1,000, committing after each batch. It warms up each kind of ID with 100,000 rows first, then runs each kind five times in a fresh database. We ran it with java -Xmx2g -cp … com.howtodoinjava.ids.InsertBenchmark 1000000 5 on Java 25.0.4.1, Linux, 2 vCPUs of an Intel Xeon at 2.10 GHz and 7 GB of RAM.

Here are the median results of the five runs. We’ll explain the UUIDv7 and ULID rows in the next sections:

IDMedian insert timeDatabase file size
UUIDv49,377 ms1,028.5 MB
UUIDv72,680 ms46.1 MB
ULID2,561 ms46.1 MB

The random UUIDv4 keys took 3.5 to 3.7 times longer to insert than the other two. The individual runs were consistent: UUIDv4 took between 8,972 and 12,878 ms, UUIDv7 between 2,532 and 3,130 ms, and ULID between 2,339 and 2,767 ms.

The file size gap is mostly specific to H2. Its storage engine writes changed pages to new places in the file and cleans up later, so random inserts leave much more old data behind. Other databases will show different numbers. The direction is the same in any B-tree, but we should measure on our own database before relying on exact figures.

3. How UUIDv7 Fixes It With a Timestamp First

The fix follows directly from the problem. If new IDs were always larger than old ones, they would go to the end of the index again, like auto-increment numbers. So what if the ID still had random bits, but started with the current time?

That’s exactly what UUIDv7 is. RFC 9562, the current UUID standard, defines it like this: the first 48 bits are the Unix time in milliseconds, followed by the version, and the remaining bits are random. The layout is easiest to see side by side:

Bit layout of UUIDv4 (random with version and variant bits), UUIDv7 (48-bit Unix millisecond time first) and ULID (48-bit time plus 80 random bits, 26 Crockford Base32 characters)
UUIDv7 and ULID start with the same 48-bit timestamp, so new IDs sort after old ones; UUIDv4 is random from the first bit.

3.1. Reading a UUIDv7

Let’s look at real values. The SampleIds program in the example project prints a few IDs of each kind. Here’s part of one run:

UUIDv4: d4ea0065-a53c-4bc5-89b0-fee24816e973
UUIDv4: c94e2f48-8140-452f-81c5-6bb50a8efeea
UUIDv7: 01a18e73-292a-7772-8d77-7a226f1e702d
UUIDv7: 01a18e73-292f-7c18-85c6-f23b3bb30fbb
First UUIDv7 was created at 2026-10-30T20:14:34.282Z

The two UUIDv4 values have nothing in common. The two UUIDv7 values share their first 11 hex digits, 01a18e73-292, because the first 12 digits are the timestamp and the two IDs were created 5 milliseconds apart. The twelfth digit is a in the first and f in the second, which is exactly those 5 milliseconds.

The first digit of the third group is the version: 4 in 4bc5 and 452f, and 7 in 7772 and 7c18. That’s how we tell the two kinds apart, and Java’s UUID class reads it for us:

UUID v4 = UUID.randomUUID();
UUID v7 = UuidCreator.getTimeOrderedEpoch();

assertThat(v4.version()).isEqualTo(4);
assertThat(v7.version()).isEqualTo(7);
assertThat(v4.variant()).isEqualTo(2);
assertThat(v7.variant()).isEqualTo(2);

The variant, the first digit of the fourth group, marks both as RFC 9562 UUIDs, and Java reports it as 2. Since both are ordinary UUIDs, every database and library that accepts a UUID accepts a UUIDv7 too.

3.2. Generating a UUIDv7 in Java

Java 25 has no built-in UUIDv7 generator, so we use a small library, uuid-creator. For ULID, which we’ll meet in section 6, we add ulid-creator from the same author:

<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>uuid-creator</artifactId>
    <version>6.1.1</version>
</dependency>
<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>ulid-creator</artifactId>
    <version>5.2.4</version>
</dependency>

The test, benchmark and Hibernate dependencies are in the example project’s pom.xml. Creating a UUIDv7 is then one call:

UUID orderId = UuidCreator.getTimeOrderedEpoch();

assertThat(orderId.version()).isEqualTo(7);

Java 26 adds a JDK method, UUID.ofEpochMillis(), which takes the timestamp as an argument, for example UUID.ofEpochMillis(System.currentTimeMillis()). We didn’t run it, because the project targets Java 25, and as the next section shows, the library method has an advantage that matters.

3.3. Proving That UUIDv7 Sorts by Creation Time

Let’s check the claim that newer UUIDv7 values are larger. Our test helper createSlowly() calls a generator 20 times, waits 2 ms after each call, and returns the IDs as strings in creation order. If the IDs sort by time, that list is already sorted:

List<String> created = createSlowly(() -> UuidCreator.getTimeOrderedEpoch().toString());

assertThat(created).isSorted();

The same check fails for UUIDv4, as expected. Sorting a copy of the list gives a different order from the creation order:

List<String> created = createSlowly(() -> UUID.randomUUID().toString());

assertThat(created.stream().sorted().toList()).isNotEqualTo(created);

These tests sort the text form, which is how RFC 9562 defines UUID order in Section 6.11. Java’s UUID.compareTo() compares the two 64-bit halves as signed numbers, which doesn’t match the text order for every UUID. For UUIDv7 it does, because the sign bit is part of the timestamp and stays 0 until the year 6429. A second test in the project confirms this with UUID objects.

This sorting is what made UUIDv7 three to four times faster to insert in section 2. New keys land on the last page of the index again.

4. The Catch: IDs Created in the Same Millisecond

There’s a catch, and it’s easy to miss. The timestamp counts milliseconds, and a busy service creates many IDs within one millisecond. Those IDs share the same timestamp, so the timestamp can’t order them. Within one millisecond, the order depends entirely on how the generator fills the bits after the timestamp.

If those bits are random, the order is random too. Java 26’s UUID.ofEpochMillis() works this way: its Javadoc leaves ordering to the timestamps we pass in. To test it on Java 25, the example project has a small generator, UuidV7.of(millis), that does the same thing: timestamp first, random bits after.

Our test helper burst() calls a generator 1,000 times in a row. Let’s give all 1,000 calls the same timestamp, as if they happened in one millisecond:

long now = System.currentTimeMillis();
List<UUID> ids = burst(() -> UuidV7.of(now));

assertThat(ids.stream().sorted().toList()).isNotEqualTo(ids);

The sorted list differs from the creation order. Within that millisecond, we’re back to random keys.

The fix is a monotonic generator, one that guarantees each ID is greater than the previous one, even within the same millisecond. RFC 9562 describes several ways to do this in Section 6.2, and UuidCreator.getTimeOrderedEpoch() implements one of them. With the same 1,000 calls, its IDs stay sorted:

List<UUID> v7 = burst(UuidCreator::getTimeOrderedEpoch);

assertThat(v7).isSorted();

We can see how it works in the last lines of the SampleIds output, which come from three calls in a row:

Same millisecond: 01a18e73-293d-790f-b5d9-558df0ba96f9
Same millisecond: 01a18e73-293d-7b60-b5da-bf6f88a19ac8
Same millisecond: 01a18e73-293d-7cd4-b5db-38ab66ca38fc

All three have the same timestamp, 01a18e73-293d. The fourth group counts up, b5d9, b5da, b5db: uuid-creator keeps a counter in those bits and increments it when the millisecond repeats. This is why we prefer getTimeOrderedEpoch() over a plain random UUIDv7.

Note that a counter lives inside one JVM. Two servers creating IDs in the same millisecond can still interleave, but their IDs are close together in time, so they still land near the end of the index.

5. The Trade-Off: UUIDv7 Reveals When a Record Was Created

The timestamp that helps the index also helps anyone who sees the ID. In section 3.1, we read the creation time of a UUIDv7 by looking at its first 12 hex digits. Java code does the same with one shift:

Instant before = Instant.now().truncatedTo(ChronoUnit.MILLIS);
UUID orderId = UuidCreator.getTimeOrderedEpoch();
Instant after = Instant.now();

Instant createdAt = Instant.ofEpochMilli(orderId.getMostSignificantBits() >>> 16);

assertThat(createdAt).isBetween(before, after);

getMostSignificantBits() returns the upper 64 bits, and shifting them right by 16 leaves the 48-bit timestamp. The decoded time falls between the moments just before and just after the ID was created.

So if a UUIDv7 order ID appears in a URL, a customer can see exactly when the order was placed. Someone who collects IDs over a day can estimate our order volume, the same leak that auto-increment numbers had in section 1. RFC 9562 says in Section 8 that UUIDv4 should be used whenever a UUID serves a security purpose. It also notes that a timestamp with a counter reveals the order of creation.

When a record needs both, we can give it two IDs: a UUIDv7 as the primary key, and a UUIDv4 for anything public. Note that Java’s UUID.timestamp() method can’t read a UUIDv7 time. It works for version 1 UUIDs only and throws UnsupportedOperationException for UUIDv4 and UUIDv7.

6. ULID: The Same Idea With a Shorter Text Form

ULID, the Universally Unique Lexicographically Sortable Identifier, is the same idea as UUIDv7. It was published as a community specification before UUIDv7 was standardized. A ULID has a 48-bit millisecond timestamp followed by 80 random bits, and no version or variant digits.

The real difference is the text form. A ULID is written as 26 characters of Crockford’s Base32, an alphabet that leaves out I, L, O and U so that they can’t be mistaken for 1 and 0. Here’s the ULID line from our SampleIds run:

ULID: 01M6776A9TQSHV6JV3987VHS9Y (created at 2026-10-30T20:14:34.298Z)

The first 10 characters are the timestamp and the last 16 are random. Compared with 36 hex characters and dashes, that’s easier to read over the phone or type into a support form. The following test checks the length, the alphabet, and that parsing ignores case:

Ulid orderId = UlidCreator.getUlid();

assertThat(UUID.randomUUID().toString()).hasSize(36);
assertThat(orderId.toString())
    .hasSize(26)
    .matches("[0-9A-HJKMNP-TV-Z]{26}");
assertThat(Ulid.from(orderId.toLowerCase())).isEqualTo(orderId);

ULID has the same same-millisecond catch as UUIDv7, so for primary keys we use the monotonic generator. A Ulid is its own type, so we convert it to text for people and to a UUID for the database:

Ulid orderId = UlidCreator.getMonotonicUlid();

String text = orderId.toString();
UUID forDatabase = orderId.toUuid();

The conversion copies all 128 bits, so it works in both directions without loss, and the ULID can live in a 16-byte UUID column. In the benchmark, ULID behaved exactly like UUIDv7, because the timestamp comes first in both.

There’s one small catch. A ULID has no version digit, so when we view it as a UUID, that position holds random data. The SampleIds run printed our ULID as 01a18e73-293a-be63-…, and b is not a valid UUID version. This test makes it visible with an all-zero random part:

Ulid orderId = new Ulid(1_767_225_600_000L, new byte[10]);

UUID asUuid = orderId.toUuid();

assertThat(asUuid.version()).isZero();
assertThat(orderId.toRfc4122().toUuid().version()).isEqualTo(4);

The database doesn’t care, because it only compares bytes. Code that checks version() would be misled, though, and toRfc4122() doesn’t really help: it writes version 4, which claims that a time-ordered ID is random.

7. Using UUIDv7 as a Primary Key With JPA and Hibernate

Let’s put the recommended ID into an entity. The simplest way is to create it in the constructor, which also solves the first problem from section 1, because the ID exists before we save anything:

@Entity
public class PurchaseOrder {

    @Id
    private UUID id;

    private int total;

    protected PurchaseOrder() {
    }

    public PurchaseOrder(int total) {
        this.id = UuidCreator.getTimeOrderedEpoch();
        this.total = total;
    }

    // getters
}

As soon as we call new PurchaseOrder(250), the order has its ID, so we can publish an event or link order lines right away. JPA uses the protected no-argument constructor when it loads an existing row, so that constructor leaves the ID alone.

If we’d rather let Hibernate assign the ID, Hibernate 7 has a UUIDv7 option on its @UuidGenerator annotation:

@Entity
public class Shipment {

    @Id
    @GeneratedValue
    @UuidGenerator(style = UuidGenerator.Style.VERSION_7)
    private UUID id;

    private String carrier;

    // constructors and getters
}

Here, the ID is null until we call persist(). Our tests with Hibernate 7.4.14 and H2 check that the IDs are version 7 and that 1,000 shipments persisted in a row get ascending IDs. Note that Hibernate marks VERSION_7 as incubating, so it may still change between releases.

In the database, the column should hold the 16 raw bytes, not a 36-character string. PostgreSQL has a native uuid type, and PostgreSQL 18 can even create UUIDv7 values with its uuidv7() function. MySQL has no UUID type, so we use BINARY(16) with UUID_TO_BIN(). We should leave its swap flag off: it reorders the bytes of version 1 UUIDs, and for UUIDv7 it would move the timestamp away from the front.

8. Which ID Should We Use?

Here’s everything we’ve seen, side by side:

CriterionUUIDv4UUIDv7ULID
Defined byRFC 9562RFC 9562Community specification
Starts withRandom bits48-bit timestamp48-bit timestamp
Text form36 hex characters36 hex characters26 Base32 characters
Sorts by creation timeNoYesYes
Ordered within one millisecondNoWith a monotonic generatorWith the monotonic generator
Reveals creation timeNoYesYes
Java 25 generatorUUID.randomUUID()UuidCreator.getTimeOrderedEpoch()UlidCreator.getMonotonicUlid()
Insert time, 1M rows in H29,377 ms2,680 ms2,561 ms

From this, the choice is short:

  • Use UUIDv7 for primary keys, generated with UuidCreator.getTimeOrderedEpoch() on Java 25. It’s a standard UUID, and it inserts like an auto-increment number.
  • Use UUIDv4 for any ID that is public and must reveal nothing, such as invitation or download links. When a record needs both, give it a UUIDv7 key and a UUIDv4 public ID.
  • Use ULID only when people read or type the ID, or when other parts of the system already use ULIDs.

On Java 26, UUID.ofEpochMillis() is fine for services that create fewer than one ID per millisecond. Above that, the monotonic library generator is the safer choice.

9. Conclusion

In this article, we started from the limits of auto-increment IDs. Random UUIDv4 keys fix them, but they slowed down inserts by 3.5 to 3.7 times in our H2 test. UUIDv7 and ULID remove that cost by putting a timestamp first, as long as the generator also keeps IDs in order within one millisecond. That timestamp reveals when each record was created, so UUIDv4 stays the right choice for public IDs, and a monotonic UUIDv7 is the best default for primary keys.

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.