Skip to main content

Hibernate ID Generation: IDENTITY vs SEQUENCE vs UUID v7, and Why Batch Inserts Break

Hibernate 7.4.14 primary keys, measured: IDENTITY turns JDBC batching off silently (10,000 inserts became 10,000 single executes), a pooled SEQUENCE needed 400 round trips, and a sequence whose INCREMENT BY disagrees with allocationSize produced negative and duplicate ids. On UUIDs, the common premise is half wrong: Style.TIME is a version-1-flagged id with a constant IP/JVM half, while real RFC 9562 version 7 ships as Style.VERSION_7 from Hibernate 7.0.0. Includes order_inserts, AUTO across six dialects, an H2 index-size comparison (PostgreSQL could not be run), and the Spring Data SELECT-before-INSERT on assigned ids.

You set hibernate.jdbc.batch_size=50, you load 10,000 rows, and nothing gets faster. Nothing is logged, nothing fails. Somewhere else, a colleague says “just use UUID v7, it fixes index problems”, and you are not sure whether Hibernate even has that, or what it would change. Both problems come from the same small decision: who creates the primary key, and when. This article walks from “what is a generated id” to the cases that bite in production — the batch that silently stops batching, the sequence whose step disagrees with the mapping, the UuidGenerator.Style.TIME that is not what its name suggests, the random UUID that makes a primary-key index many times larger, and the Spring Data save() that runs a SELECT before every INSERT. It is deliberately the sequel to Hibernate 7 Batch Inserts: How to Prove Batching Is Working. That article proves, on 30 rows, that IDENTITY defeats batching and sweeps allocationSize and batch_size. This one does not repeat those sweeps. It scales to 10,000 rows and counts JDBC calls directly, adds order_inserts, the sequence/mapping mismatch, AUTO across six databases, and everything UUID. Every figure comes out of a companion module whose 12 tests write the transcripts and assert the same numbers: asmhatre/hibernate-demo/id-generation, with the captured output in output/. Deeper material sits in collapsible boxes inside each section, so you can read straight through or open only what you need.
Versions. Hibernate ORM 7.4.14.Final (the newest 7.x GA on Maven Central on 2026-11-05; 8.0.0.Beta4 is also listed and is not a release), Spring Boot parent 4.1.1 (4.1.2 was already published; the sibling modules stay on 4.1.1 so numbers are comparable), H2 2.4.240, uuid-creator 6.1.1, JDK 25.0.3, Maven 3.9.11. Spring Boot 4.1.1 manages Hibernate 7.4.5.Final; the module overrides hibernate.version. API shapes were read with javap from the shipped jar.

What was not run. No Docker daemon was reachable while writing, so nothing here was measured on PostgreSQL. The PostgreSQL code paths exist in the module (Testcontainers) but every committed transcript says SKIPPED for them. Index sizes below are from H2’s own storage engine and are labelled that way. MySQL/InnoDB statements are sourced from documentation, not executed. All timings are one run on one laptop and are indicative only.

A primary key is a number the database needs before it can store your row

Every entity needs an id. There are only three places that id can come from, and the difference between them is when your program learns it.
Who makes the id, and when does your code learn it? IDENTITY persist(entity) INSERT runs right now database returns the id SEQUENCE persist(entity) id from memory block INSERT waits for flush one trip to the sequence per block of ids UUID persist(entity) app generates the id INSERT waits for flush Only the red box forces a database round trip per row.
The picture is the whole article in one line. With IDENTITY the database creates the id as a side effect of the INSERT, so Hibernate cannot know the id without running the INSERT first. With SEQUENCE Hibernate gets ids ahead of time, in blocks, and can hold the INSERTs back. With a UUID the id is made in your JVM and no database call is needed at all. Holding INSERTs back is what lets Hibernate group them into JDBC batches — so the red box is the one that breaks batching.

Going deeper

IDENTITY is the simple one, and its INSERT cannot wait

Here is the entity mapping, from IdentityItem.java:
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
On H2 and PostgreSQL that becomes an identity column. This is what the module did when it called persist() and then rolled the transaction back without ever flushing or committing (transcript: 02-identity-vs-sequence.txt):
== IDENTITY: persist() runs the INSERT immediately
-- persist(item)  -- no flush(), no commit() yet
   sql> insert into IdentityItem (name,id) values (?,default)
item.getId() right after persist : 1
rows visible after rollback : 0
The INSERT ran inside persist(), and getId() already had a value. The rollback afterwards made the row disappear, but the round trip had happened. For one entity that is irrelevant. For 10,000 it is the next section.
This is not a bug and it does not warn you. Hibernate is doing the only thing that works: the id is needed for the persistence context, so the row is inserted at once. Nothing in the log says batching was skipped.
Intermediate: why Hibernate cannot “ask for the key later”
Two things depend on the id the moment persist() returns: the entity must be registered in the persistence context under its id (so a later find returns the same instance), and any @OneToMany child that points back needs a parent id for its foreign key. With a sequence or a UUID the id exists before the INSERT, so Hibernate can defer the INSERT to flush time. With IDENTITY it does not, so the INSERT is moved forward to persist time. The 10,000-row run in the next section shows the result: one single statement per row.

Going deeper

Batching: IDENTITY quietly turns it off, and the numbers are not subtle

The test inserts 10,000 rows of five kinds, calls flush() and clear() every 50 rows, and sets hibernate.jdbc.batch_size=50. Instead of trusting a Hibernate statistic, the module wraps the JDBC Connection in a counting proxy (JdbcCounter.java) and counts what Hibernate asks of JDBC. The loop is the standard pattern, from A03_BatchInsertTest.java:
    static long insert(Db db, int n, Function<Integer, Object> factory) {
        JdbcCounter.reset();
        long t0 = System.nanoTime();
        try (Session s = db.sf().openSession()) {
            var tx = s.beginTransaction();
            for (int i = 0; i < n; i++) {
                s.persist(factory.apply(i));
                if ((i + 1) % BATCH == 0) { s.flush(); s.clear(); }
            }
            tx.commit();
        }
        return (System.nanoTime() - t0) / 1_000_000;
    }
The result, from 03-batch-inserts.txt (H2; times are milliseconds, indicative):
IDENTITY                          331 ms   prepared=10000 (insert=10000, sequence=0) addBatch=0 executeBatch=0 executeSingle=10000
SEQUENCE allocationSize=1         226 ms   prepared=10200 (insert=200, sequence=10000) addBatch=10000 executeBatch=200 executeSingle=10000
SEQUENCE allocationSize=50         46 ms   prepared=400 (insert=200, sequence=200) addBatch=10000 executeBatch=200 executeSingle=200
UUID v4 (GenerationType.UUID)      98 ms   prepared=200 (insert=200, sequence=0) addBatch=10000 executeBatch=200 executeSingle=0
UUID v7 (Style.VERSION_7)          64 ms   prepared=200 (insert=200, sequence=0) addBatch=10000 executeBatch=200 executeSingle=0
Read the counts, not the milliseconds. addBatch is a row queued; executeBatch is one network exchange carrying up to 50 rows; executeSingle is one row, one exchange.
Round trips to the database for 10,000 inserts (executeBatch + single executes) IDENTITY10,000 SEQUENCE, allocationSize=1200 batches + 10,000 sequence calls SEQUENCE, allocationSize=50400 (200 batches + 200 sequence calls) UUID (v4 or v7)200 (batches only) allocationSize=1 is no better than IDENTITY: the batching works, but every id costs a round trip.
The argument of the chart: IDENTITY sends 10,000 single statements. allocationSize=1 looks batched (200 batches) but spends 10,000 more calls fetching ids one at a time, so the total is slightly worse than IDENTITY. Only the pooled sequence and the UUID bring the total down to hundreds. On H2 in memory a round trip is nearly free, which is why the millisecond gaps here are small; over a network each of those 10,000 trips costs real latency, and that is the part this laptop run cannot show. And to see what batch_size itself buys, the same pooled-sequence entity with the setting removed (03-batch-inserts.txt again):
Same SEQUENCE allocationSize=50 entity, hibernate.jdbc.batch_size NOT set:
SEQUENCE allocationSize=50         68 ms   prepared=10201 (insert=10000, sequence=201) addBatch=0 executeBatch=0 executeSingle=10201
No batching, 10,201 single executes, and again no error and no warning.
The fingerprint of “batching is off”: addBatch=0 and executeSingle roughly equal to your row count. If you only watch Hibernate’s entityInsertCount you will never see it, because that counts entities, not round trips.
Intermediate: hibernate.order_inserts and parent/child graphs
A batch can only hold rows for the same SQL statement. If you persist a parent with three children, then the next parent with three children, the insert queue reads P, C, C, C, P, C, C, C … and each change of table ends the batch. The module persisted 1,000 parents with 3 children each, sequence ids, batch_size=50 (03b-order-inserts.txt):
order_inserts=false   266 ms   prepared=2082 (insert=2000, sequence=82) addBatch=4000 executeBatch=2000 executeSingle=82
order_inserts=true    118 ms   prepared=122 (insert=40, sequence=82) addBatch=4000 executeBatch=80 executeSingle=82
Same 4,000 rows queued; 2,000 batches without ordering versus 80 with hibernate.order_inserts=true, which sorts the queue by entity so all parents go first, then all children. The entities are Parent.java and Child.java. The setting costs a sort per flush; the existing article uses it but does not isolate it.
Reference: what is and is not measurable from inside Hibernate
org.hibernate.stat.Statistics in 7.4.14 exposes getEntityInsertCount(), getPrepareStatementCount() and getCloseStatementCount() and nothing about JDBC batches (checked with javap). The transcript line Hibernate Statistics for the whole session factory ... in 03-batch-inserts.txt shows both numbers, but they cannot tell you whether the 10,000 inserts were batched. That is why the proxy exists. A datasource-proxy library does the same job in a real application.

Going deeper

  • Existing article for the 30-row version and the allocationSize/batch_size sweeps: Hibernate 7 Batch Inserts.
  • Two JDBC-level switches not tested here: PostgreSQL’s reWriteBatchedInserts and MySQL’s rewriteBatchedStatements (driver documentation).
  • HikariCP and Hibernate 7 pooling defaults — the connection side of the same throughput question.

SEQUENCE is fast because Hibernate takes ids in blocks of 50

A sequence is a counter that lives in the database. If Hibernate asked for one value per entity (allocationSize=1) that would be one trip per row, as the chart showed. The default is a block of 50: Hibernate asks once, then hands out 50 ids from memory. This is the pooled optimizer. The mapping, from SeqPooledItem.java:
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_pooled_item")
    @SequenceGenerator(name = "seq_pooled_item", sequenceName = "seq_pooled_item", allocationSize = 50)
    private Long id;
INCREMENT BY 50: the database moves in steps, Hibernate fills in the gaps database sequence next value 151101151 block 1 in memoryids 1 – 50 block 2 in memoryids 51 – 100 block 3 in memoryids 101 – 150 120 entities need three blocks, but the very first allocation asks twice (see the transcript), so four calls. Later runs need only the next block boundary: the second 120 entities cost two calls.
The diagram says why the number of sequence calls is not simply rows / 50. Measured (02-identity-vs-sequence.txt), first allocationSize=1, then 50, for 120 entities:
== SEQUENCE, allocationSize = 1: one sequence call per entity
persist 120 entities, one transaction
sequence calls : 120   inserts : 120
first ids : [1, 2, 3, 4, 5]  ...  last id : 120

== SEQUENCE, allocationSize = 50 (pooled optimizer): one sequence call per 50 entities
persist 120 entities, one transaction
sequence calls : 4   inserts : 120
first ids : [1, 2, 3, 4, 5]  ...  last id : 120
sequence statements : [select next value for seq_pooled_item]
(the first allocation of a fresh sequence costs two calls: one to learn the starting value, one to learn the upper bound of the first block of 50)
persist another 120 entities, same SessionFactory : sequence calls : 2
120 calls versus 4, then 2 on the second run. The DDL Hibernate generated shows why a block of 50 works: the sequence is created with increment by 50, so every call to the database moves the counter by exactly the block size. From the same transcript:
create sequence seq_one_item start with 1 increment by 1;
create sequence seq_pooled_item start with 1 increment by 50;
create sequence SeqDefaultItem_SEQ start with 1 increment by 50;
create table hibernate_sequences (next_val bigint, sequence_name varchar(255) not null, primary key (sequence_name));
insert into hibernate_sequences(sequence_name, next_val) values ('default',0);
create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id));
create table SeqDefaultItem (id bigint not null, name varchar(255), primary key (id));
create table SeqOneItem (id bigint not null, name varchar(255), primary key (id));
create table SeqPooledItem (id bigint not null, name varchar(255), primary key (id));
create table TableItem (id bigint not null, name varchar(255), primary key (id));
Intermediate: the optimizers Hibernate ships, and the TABLE generator
The enum StandardOptimizerDescriptor lists the strategies: NONE, HILO, LEGACY_HILO, POOLED, POOLED_LO, POOLED_LOTL (00-javap.txt). Whenever the mapped step is above 1 Hibernate picks a pooled variant by default; the setting hibernate.id.optimizer.pooled.preferred chooses which. “Pooled” means the sequence value is the top of the block, so other tools inserting with nextval directly get sensible values.

GenerationType.TABLE does the same with a row in a table instead of a sequence. It needs a read with a lock and an update per block, and the module captured both statements (the generator’s own JDBC work is invisible to Hibernate’s statement inspector, which is why the counting proxy shows them):
== TABLE generator: one read + one update of the generator row per allocation
persist 120 entities: statements other than the 120 INSERTs : 8 (seen by the JDBC counter; Hibernate's StatementInspector does not see them)
   sql> select next_val from hibernate_sequences where sequence_name=? for update
   sql> update hibernate_sequences set next_val=? where next_val=? and sequence_name=?
Eight statements for 120 rows is four blocks of 50, each a select ... for update plus an update. It works everywhere, serialises on a row lock, and is the slowest option; use it only for a database without sequences or identity.
Intermediate: sequence names when you name nothing, and what AUTO picks
With a bare @GeneratedValue on a Long (AutoLongItem.java) Hibernate 7 creates one sequence per entity, named <Entity>_SEQ, step 50. The old shared hibernate_sequence (step 1) comes back only with a naming switch (02b-default-sequence-naming.txt):
== hibernate.id.db_structure_naming_strategy = standard   (entities: AutoLongItem, SeqDefaultItem)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create sequence SeqDefaultItem_SEQ start with 1 increment by 50;
== hibernate.id.db_structure_naming_strategy = legacy   (entities: AutoLongItem, SeqDefaultItem)
create sequence hibernate_sequence start with 1 increment by 1;
That matters when you upgrade an old schema: the legacy strategy expects hibernate_sequence with increment 1. What AUTO turns into depends on the dialect. The module builds the CREATE script for six dialects without connecting to any of them (05-auto-and-uuid-per-dialect.txt). PostgreSQL gets a sequence; MySQL has none, so Hibernate emulates it with a one-row table:
== PostgreSQL  (org.hibernate.dialect.PostgreSQLDialect)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id));
== MySQL  (org.hibernate.dialect.MySQLDialect)
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)) engine=InnoDB;
create table AutoLongItem_SEQ (next_val bigint) engine=InnoDB;
For a UUID field, AUTO means a random version 4 UUID (asserted in 01-uuid-styles.txt). Only SQL Server, Oracle, MariaDB, PostgreSQL and H2 got a sequence for a Long in this run; MySQL got the table. This is DDL-only evidence — no MySQL, Oracle or SQL Server server was started.

Going deeper

When the database and the mapping disagree about the step, ids go wrong

Everything in the last section assumed the sequence in the database really does INCREMENT BY 50. If you manage the schema with Flyway or Liquibase and the sequence was created with a step of 1, Hibernate and the database disagree. The module reproduces that on purpose: mapping allocationSize=50, schema created by hand with the statements from A04_MismatchAndAutoTest.java:
    static final String[] TABLE_AND_SEQ_INC1 = {
        "create table SeqPooledItem (id bigint not null primary key, name varchar(255))",
        "create sequence seq_pooled_item start with 1 increment by 1"};
By default, Hibernate catches it at startup (04-sequence-increment-mismatch.txt):
== hibernate.id.sequence.increment_size_mismatch_strategy = (unset)
SessionFactory startup FAILED: MappingException -> MappingException: The increment size of the [seq_pooled_item] sequence is set to [50] in the entity mapping but the mapped database sequence increment size is [1]
That is the good outcome. The setting hibernate.id.sequence.increment_size_mismatch_strategy has four values (LOG, EXCEPTION, FIX, NONE; see 00-javap.txt). With LOG or NONE the application starts, and then:
== hibernate.id.sequence.increment_size_mismatch_strategy = LOG
SessionFactory started
application instance A : ids [1, -47, -46]
application instance B : FAILED after ids [-46, -45, -44] : ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.CONSTRAINT_D PRIMARY KEY ON PUBLIC.SEQPOOLEDITEM(ID) ( /* key:-46 */ CAST(-46 AS BIGINT), 'a2')"; SQL statement:
Ids -47 and -46. The pattern is consistent with Hibernate treating the value the sequence returned as the top of a block of 50 and filling the block downwards, below zero (my reading of the output; I did not trace the optimizer code); the second application instance then produced -46 again and the primary key rejected it. With FIX Hibernate adopts the database’s step instead (and quietly stops pooling), per the same transcript:
== hibernate.id.sequence.increment_size_mismatch_strategy = FIX
SessionFactory started
application instance A : ids [1, 2, 3]
application instance B : ids [4, 5, 6]
The fingerprint of a step mismatch: negative or duplicate ids, or a unique-constraint violation that appears only when you run two instances. The default EXCEPTION stops it at startup on this run, where Hibernate could read the sequence’s metadata; I did not test a setup where it cannot (for example JDBC metadata access disabled), so do not assume the guard is always there. Keep the mapping and the migration script in one place.
The reverse mismatch is milder. Mapping allocationSize=1 over a sequence that steps by 50 simply wastes ids (04b-sequence-step-bigger-than-mapping.txt):
ids : [1, 51, 101, 151]

Going deeper

  • The five strategy runs, including the exact exception text, are in 04-sequence-increment-mismatch.txt.
  • If you generate ids from several applications or tools, give every writer the same step; FIX is a repair for startup, not a design.

UUIDs need no round trip, but “UUID” is five different generators

A UUID is a 128-bit value your program can make on its own. That removes the sequence trip entirely (the chart: 200 batches, nothing else) and lets rows from several services be merged later. In Hibernate 7 you ask for one with @GeneratedValue(strategy = GenerationType.UUID) or with the annotation @UuidGenerator, whose style chooses the flavour. Before building anything, the module asked the jar what exists (01-uuid-styles.txt):
Style values : [AUTO, RANDOM, TIME, VERSION_6, VERSION_7]
== Does the JDK itself generate version 7? (public static methods of java.util.UUID)
static factories: [fromString, nameUUIDFromBytes, randomUUID]
Two corrections to popular belief fall out of those lines. First, Hibernate does have a real version 7: Style.VERSION_7 exists, and so does VERSION_6. Second, the JDK has none: java.util.UUID offers only randomUUID() (version 4), nameUUIDFromBytes (version 3) and fromString. Were these styles always there? javap of five jars says no (00b-style-by-version.txt; hand-run, not regenerated by the script):
hibernate-core-6.5.2.Final: AUTO RANDOM TIME | UuidVersion7Strategy in jar: 0
hibernate-core-6.6.8.Final: AUTO RANDOM TIME | UuidVersion7Strategy in jar: 0
hibernate-core-7.0.0.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1
hibernate-core-7.4.0.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1
hibernate-core-7.4.14.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1
VERSION_6 and VERSION_7 are absent from 6.5.2 and 6.6.8 and present from 7.0.0.Final, the oldest 7.x I inspected. So “UUID v7 in Hibernate” means Hibernate 7, not 6. Here is each style’s UUID, and what it did to ordering. “Out of order” counts neighbours (ids created one after another) whose text, which sorts the same as the bytes in a database index, is smaller than the previous one:
128 bits, left to right: what sorts first is what comes first v7 48 bits: Unix milliseconds 7 12 bits 62 bits random v4 random 4 random random Amber boxes are the version nibble and the 2-bit variant. Green is random or counter. Put the clock in the first bytes and ids sort by creation time; put randomness there and they do not.
The diagram is the whole idea of version 7 (RFC 9562): the first 48 bits are Unix milliseconds, so an index that sorts bytes sorts by creation time. Version 4 is random from the first byte. The measured version, 2,000 persists in one transaction each, from 01-uuid-styles.txt:
== GenerationType.UUID (JPA)  (UuidV4Item, 2000 persists in one transaction)
first three : [5b257f75-63bc-4f48-905d-65bac3459d13, 8b636cac-72ba-4740-baa4-960522598e0f, 7563a4d5-dfe5-4be5-b1f5-55e71a83eaec]
version nibble of every id : [4], variant : [2]
string/byte order vs generation order : 1017 of 1999 neighbours are out of order
== Style.VERSION_7  (UuidV7Item, 2000 persists in one transaction)
first three : [01a1ad07-6af9-7f60-91e5-a074a2e61b15, 01a1ad07-6af9-7f60-bafb-92ecaabdd57e, 01a1ad07-6af9-7f61-84be-68a7813aa2e1]
version nibble of every id : [7], variant : [2]
string/byte order vs generation order : 0 of 1999 neighbours are out of order
first 48 bits as Unix milliseconds : 1793904306937 -> 2026-11-05T18:45:06.937Z
GenerationType.UUID: version nibble 4, and 1,000-odd of 1,999 neighbours out of order — effectively random. Style.VERSION_7: version nibble 7, 0 of 1,999 out of order, and the first 48 bits decode to the real wall-clock time of the run. The mapping that did it is one line, from UuidV7Item.java:
    @Id
    @UuidGenerator(style = UuidGenerator.Style.VERSION_7)
    private UUID id;
The brief you may have read is half wrong. “Use @UuidGenerator(style = TIME) for time-ordered UUIDs, and Hibernate has no v7” is the common claim. Hibernate 7 has VERSION_7 (and VERSION_6). TIME is something else, which the next section opens up.
Intermediate: three other ways to get a version 7
1. The uuid-creator library. Version 6.1.1 is the newest GA on Maven Central (checked 2026-11-05). Its UuidCreator.getTimeOrderedEpoch() returns a version 7, and the module plugs it in through @UuidGenerator(algorithm = ...), whose type is Hibernate’s UuidValueGenerator interface (one method, shown in 00-javap.txt). The adapter is UuidCreatorV7.java, used by UuidCreatorV7Item.java.

2. Write it yourself, to see the layout. Thirteen lines are enough, using the 12-bit field as a counter so two ids made in the same millisecond still increase. From UuidV7Generator.java:
    public synchronized UUID next() {
        long now = System.currentTimeMillis();
        if (now > lastMs) {
            lastMs = now;
            counter = RNG.nextInt(1 << 11);          // start in the lower half so a burst has room to count up
        } else {
            counter++;                                // same millisecond (or the clock stepped back): keep counting
            if (counter > 0xFFF) { lastMs++; counter = 0; }   // counter exhausted: borrow the next millisecond
        }
        long msb = (lastMs << 16) | (0x7L << 12) | counter;
        long lsb = (RNG.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | 0x8000_0000_0000_0000L;
        return new UUID(msb, lsb);
    }
The test asserts version 7 and zero out-of-order neighbours for it as well; it is an exhibit, not a recommendation, because the built-in one does the same job and is maintained for you.

3. Your own annotation. If you want a custom name such as @Uuid7, or the same generator on a String id, build it on @IdGeneratorType and BeforeExecutionGenerator. This is not worth a module file, so download id-generation-CustomV7IdGenerator.java. It was compiled against hibernate-core 7.4.14.Final and run once against H2 with a UUID and a String id; both came back as version 7.
Reference: cost of generating an id, and the clock
01b-uuid-generation-rate.txt times one million-plus ids per generator, single thread, no database. All generators land between tens of nanoseconds and about a microsecond, noisy between runs, which is far below one database round trip; the figures are not stable enough to rank the libraries. A time-ordered id depends on the clock: a clock that steps backwards can reorder ids, and ids from different machines interleave by clock skew, so “sorted by time” means “sorted by each writer’s idea of time”. That is a property of the format, not something the module measured.

Going deeper

  • RFC 9562 — the standard for versions 6, 7 and 8 and the bit layouts.
  • The generator classes behind each style (UuidVersion7Strategy, UuidVersion6Strategy, CustomVersionOneStrategy, StandardRandomStrategy) can be listed from the jar; UuidVersion7Strategy’s shape is in 00-javap.txt.
  • uuid-creator on GitHub.

Style.TIME is flagged version 1, but it is not a clock you can sort by across restarts

The name suggests “time-ordered”. Inside one running JVM it does increase (0 neighbours out of order in the earlier run). But it is not an RFC version 1 and it is not ordered by time across processes. The module generated four ids 30 ms apart and printed them whole (same file, 01-uuid-styles.txt):
== Style.TIME is not a clock-ordered v1: what changes between ids created 30 ms apart
c0a802bb-a1ad-1754-81a1-ad076be82d70   msb=c0a802bba1ad1754 lsb=81a1ad076be82d70
c0a802bb-a1ad-1754-81a1-ad076c092d71   msb=c0a802bba1ad1754 lsb=81a1ad076c092d71
c0a802bb-a1ad-1754-81a1-ad076c282d72   msb=c0a802bba1ad1754 lsb=81a1ad076c282d72
c0a802bb-a1ad-1754-81a1-ad076c482d73   msb=c0a802bba1ad1754 lsb=81a1ad076c482d73
distinct most-significant halves among them : 1  (IPv4 address of this host + a per-JVM value, fixed for the life of the JVM)
first 32 bits equal this host's IPv4 address : true
UUID.timestamp() (what a real version 1 would use as its clock) is the same for all four : 1 distinct value(s)
Look at the left half (msb): it is identical for every id. It is the machine’s IPv4 address in the first four bytes (c0a802bb is a private-network address; the test confirms it equals this host’s IPv4 address) followed by a value fixed per JVM. The clock lives in the right half, followed by a counter. A real version 1 UUID would carry a changing 60-bit timestamp, which is why UUID.timestamp() returns the same value for all four here. The bytecode agrees: the constructor of CustomVersionOneStrategy builds the high half from Helper.getAddressBytes() and Helper.getJvmIdentifierBytes(), and each generated value adds System.currentTimeMillis() and a counter to the low half (read with javap -c). The consequence for an index: ids from one JVM append in order, but ids from different hosts, or from the same host after a restart, sort first by IP and JVM value, and the time comes last. Insertions then land in as many separate “lanes” as you have JVM lifetimes, not at one right-hand edge. It is much better than random, and worse than version 7. It also puts a private network address into every primary key, which you may not want in URLs.
Practical rule: if you want ids that sort by creation time, ask for Style.VERSION_7. Do not assume that TIME means that, and note that a bare @GeneratedValue on a UUID (AUTO) gave random version 4 in the run above.

Going deeper

  • The full 2,000-id runs for every style, including the hand-written and library versions, are in 01-uuid-styles.txt.
  • Style.VERSION_6 is version 1 re-ordered so the timestamp comes first; it also showed 0 out-of-order neighbours. For new code, version 7 is the one RFC 9562 steers towards.

Random UUIDs make the primary-key index larger, because every insert lands somewhere new

A primary key is stored in a B-tree: sorted pages, with the newest rows at one edge if the key increases. The point of sorting is that the next insert is cheap if its place is already in memory.
Where do the next ten inserts go? Increasing keys (sequence, v7) all ten go to the right-hand edge, which is warm in memory Random keys (v4) every page can be hit, so all of them must stay in memory or be read back, and full pages split
The diagram is the mechanism. With increasing keys only the last page is active. With random keys any page can be the target, so a table bigger than memory means reading a page from disk to insert one row, and a page that is full has to split into two half-full pages, which is how a random-key index ends up larger and sparser than an ordered one. The module measured the effect where it could: H2 with a file database, 200,000 rows per key type, same table shape. The numbers are the size of the database file after it is closed (06-index-size-h2.txt):
variant                                   load ms     file KiB
bigint sequence (allocationSize 50)          1244         4944
UUID v4  random                              2280       101428
UUID v7  time-ordered                        1314        12960
UUID v7  as CHAR(36)                         1528        52948
The random UUID database is roughly 20 times the size of the sequence one and roughly 8 times the version 7 one, for exactly the same rows (across the four runs I made, 18 to 22 times and 7 to 8.4 times; the committed transcript is the last run). Three honest caveats. This is H2’s storage engine (copy-on-write pages), so the file also contains superseded pages that random inserts create more of; it is evidence for the shape of the problem, not a PostgreSQL index size. The PostgreSQL measurement (pg_relation_size, pgstatindex leaf density) is written in A05_IndexLocalityTest.java and its transcript 06b-index-size-postgres.txt says SKIPPED: I could not run it. And the file size covers the table and the index together, so do not read the ratios as index-only ratios. The last two rows are about storage format, not ordering. The same version 7 values stored as CHAR(36) text took about four times the file of the native 16-byte type. Hibernate 7 maps a Java UUID to a native type where the database has one (DDL from 05-auto-and-uuid-per-dialect.txt, no server involved):
== MySQL  (org.hibernate.dialect.MySQLDialect)
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)) engine=InnoDB;
create table AutoLongItem_SEQ (next_val bigint) engine=InnoDB;
create table AutoUuidItem (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB;
create table IdentityItem (id bigint not null auto_increment, name varchar(255), primary key (id)) engine=InnoDB;
create table UuidCharItem (id varchar(36) not null, name varchar(255), primary key (id)) engine=InnoDB;
create table UuidV7Item (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB;
On MySQL and MariaDB that is binary(16), on PostgreSQL and H2 uuid, on SQL Server uniqueidentifier, on Oracle raw(16). Choosing @JdbcTypeCode(SqlTypes.CHAR) (UuidCharItem.java) gives char(36) or varchar(36), which is easier to read in a console and costs more than twice the bytes in every index entry.
Intermediate: MySQL/InnoDB makes the primary key the table (documented, not executed)
In InnoDB the table is stored in the primary-key B-tree (the clustered index), and every secondary index entry carries a copy of the primary key. That is why a wide random primary key hurts twice: row inserts scatter across the table itself, and every secondary index gets bigger. This is from the MySQL manual (InnoDB clustered and secondary indexes); I did not run MySQL, so no number in this article comes from it. PostgreSQL stores table rows in a separate heap, so a random key mostly costs index locality, not table locality; I did not measure that either.

Going deeper

With assigned UUIDs, Spring Data save() runs a SELECT before every INSERT

Making the id yourself (new Book(UUID.randomUUID(), ...)) is tempting, and it hides a cost. Spring Data decides between persist and merge by asking “is this entity new?”, and for an entity with a non-null id and no version it assumes not new, calls merge, and Hibernate loads the row first to see if it exists. The module tried four shapes (07-spring-data-save.txt), starting with the plain assigned one, AssignedBook.java:
== AssignedBook: UUID.randomUUID() assigned in the constructor, no @Version
   sql> select ab1_0.id,ab1_0.title from assigned_book ab1_0 where ab1_0.id=?
   sql> insert into assigned_book (title,id) values (?,?)
A select for a row that cannot exist yet, then the insert. Over 100 rows via saveAll that was 100 selects. Two small changes remove it. A boxed @Version field tells Spring Data the instance is new while it is null (VersionedBook.java):
    @Id
    private UUID id;
    @Version
    private Long version;
== VersionedBook: same, plus a boxed @Version column
   sql> insert into versioned_book (title,version,id) values (?,?,?)
Or let the entity answer, by implementing Persistable (PersistableBook.java), and the generated-id case needs neither: a @UuidGenerator id is null before persist, so the instance is new on its face. The transcript has all four plus the 100-row runs (selects: 100 for assigned ids, 0 for generated ones).
The fingerprint: a select ... where id=? immediately before every insert in the SQL log, for an entity with an application-assigned id. Let Hibernate generate the id (version 7 keeps the index friendly) unless you have a reason to assign it.

Going deeper

Sequential ids tell outsiders how many rows you have, and a version 7 tells them when

An id exposed in a URL is information. A sequence id such as /orders/10412 lets a visitor estimate your volume and walk the numbers. A random UUID gives neither. A version 7 gives away less than a counter but more than v4: the first 48 bits are the creation time in milliseconds, and the test decodes them (the “first 48 bits as Unix milliseconds” line in the VERSION_7 block above). RFC 9562’s security and privacy considerations discuss this kind of timestamp exposure. If creation time of a record is sensitive, keep the time-ordered id internal and expose a separate random public identifier.

A decision table: what was measured, and what is my judgement

Key choiceMeasured hereJudgement (not measured)
IDENTITYINSERT runs at persist(); 10,000 rows = 10,000 single executes, addBatch=0 (H2)Fine for low-volume tables. Wrong choice for bulk loads.
SEQUENCE, allocationSize=50400 round trips for 10,000 rows; ordered, compact keysBest default for bulk-written tables on databases with sequences. Keep the DB step equal to the mapping.
SEQUENCE, allocationSize=110,000 sequence calls; worse than IDENTITY in round tripsOnly if some other tool needs consecutive values.
TABLELock-and-update per block of 50Last resort.
UUID v4 (GenerationType.UUID)Batches fine, 0 sequence calls; random order; largest H2 file (about 20x the sequence one)Good when ids must be unguessable and the table is small or read-mostly.
UUID v7 (Style.VERSION_7)Batches fine, 0 out-of-order neighbours in one JVM; about 2.6x the sequence H2 fileBest UUID choice for large, insert-heavy tables. Leaks creation time. Cross-node order is only as good as the clocks.
Style.TIMEVersion-1 flag, constant IP and JVM half, time in the low halfAvoid for new work; not a sortable clock across restarts.
Assigned ids with Spring DataOne extra SELECT per save (100 for 100 rows)Add @Version or Persistable, or let Hibernate generate.
Should you even change your key strategy? Probably not, unless a number tells you to. A table that gets a few hundred inserts a day gains nothing from v7 and the migration is real work. Look first at whether you bulk-insert at all, and whether IDENTITY is the thing standing in your way; a sequence with allocationSize=50 fixes that without touching the key type. Move to version 7 for a large, hot, insert-heavy table where ids from several writers must be generated without a coordinator, and measure on your database first: the index-size ratios above are from H2 and I could not confirm them on PostgreSQL. Do not pick a key for its performance and then expose it in URLs without considering what it reveals.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.