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.Beta4is 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 overrideshibernate.version. API shapes were read withjavapfrom 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 saysSKIPPEDfor 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.Going deeper
- Hibernate 7 Batch Inserts: How to Prove Batching Is Working — the 30-row proof that
IDENTITYdefeatsbatch_size, and theallocationSizesweep. - save, update and saveOrUpdate in Hibernate 7 — what
persistandmergedo with an id. - Jakarta Persistence 3.2 — the
GenerationTypeenum (AUTO,IDENTITY,SEQUENCE,TABLE,UUID).
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
- Read the DDL for every strategy side by side in 02-identity-vs-sequence.txt.
- Hibernate User Guide — IDENTITY generator.
Batching: IDENTITY quietly turns it off, and the numbers are not subtle
The test inserts 10,000 rows of five kinds, callsflush() 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.
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=0andexecuteSingleroughly equal to your row count. If you only watch Hibernate’sentityInsertCountyou 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_sizesweeps: Hibernate 7 Batch Inserts. - Two JDBC-level switches not tested here: PostgreSQL’s
reWriteBatchedInsertsand MySQL’srewriteBatchedStatements(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;
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
SequenceMismatchStrategyand the optimizer enum are in 00-javap.txt.- Hibernate User Guide — sequence generator and optimizers.
- Hibernate 5 to 7 migration cheat sheet — the default-naming change in context.
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 doesINCREMENT 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;
FIXis 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:
== 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 hasVERSION_7(andVERSION_6).TIMEis 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
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:
3. Your own annotation. If you want a custom name such as
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 forStyle.VERSION_7. Do not assume thatTIMEmeans that, and note that a bare@GeneratedValueon aUUID(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_6is 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.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
- The measurement code, including the PostgreSQL part to run on a machine with Docker: A05_IndexLocalityTest.java.
- PostgreSQL
pgstattuple/pgstatindex— leaf density and fragmentation of a B-tree index. - @JdbcTypeCode on PostgreSQL — the same annotation used for a different column type.
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: aselect ... where id=?immediately before everyinsertin 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
- Spring Data JPA complete guide and merging vs refreshing entities — what
mergedoes. - Spring Data JPA reference — entity state detection strategies.
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 choice | Measured here | Judgement (not measured) |
|---|---|---|
IDENTITY | INSERT 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=50 | 400 round trips for 10,000 rows; ordered, compact keys | Best default for bulk-written tables on databases with sequences. Keep the DB step equal to the mapping. |
SEQUENCE, allocationSize=1 | 10,000 sequence calls; worse than IDENTITY in round trips | Only if some other tool needs consecutive values. |
TABLE | Lock-and-update per block of 50 | Last 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 file | Best UUID choice for large, insert-heavy tables. Leaks creation time. Cross-node order is only as good as the clocks. |
Style.TIME | Version-1 flag, constant IP and JVM half, time in the low half | Avoid for new work; not a sortable clock across restarts. |
| Assigned ids with Spring Data | One 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 whetherIDENTITYis the thing standing in your way; a sequence withallocationSize=50fixes 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
- Companion module: asmhatre/hibernate-demo/id-generation and its captured output.
- Hibernate 7 Batch Inserts: How to Prove Batching Is Working (the predecessor to this article).
- Hibernate 5 to 7 migration cheat sheet.
- RFC 9562: Universally Unique IDentifiers (UUIDs).
- Hibernate 7.4 User Guide — identifiers.
- MySQL manual — InnoDB clustered and secondary indexes.
- PostgreSQL — pgstattuple.
No Comments yet!