What you'll learn: By the end of this lesson you will be able to explain what an identifier has to guarantee, lay out the bits of UUIDv4, UUIDv7, ULID and a Snowflake-style id, say why time-ordered keys keep a B-tree index compact and random keys scatter it, run a Java lab that counts pages and buffer misses for each format, name what each format costs (size, coordination, clock dependence, leaked creation time), and pick one for a table and defend the choice.
A post office gives every parcel a tracking number. If each counter invents numbers independently, two parcels can get the same one. If every counter phones head office for each number, the queue never ends. A third scheme is for each counter to put its own counter code in front of its own running count, so no two counters can collide and nobody has to phone. The last question is how the numbers will be filed: if the numbers arrive in a random order, the filing cabinet has to be opened at a random drawer every time, but if they arrive in roughly increasing order, you always add to the last drawer.
This lesson is the dedicated treatment of that trade-off. The overview lesson, IDs, clocks and ordering, gives the working definitions and the clock problem; this one goes into the bit layouts, the index effect, a lab and a failure scenario.
Verified with Java 25 and PostgreSQL 18 documentation, RFC 9562 and the ULID specification, on 2026-11-08.
The core idea
An identifier has to be unique, and the cheap way to be unique without asking anyone is to build the id from fields that cannot collide: random bits, a timestamp, a worker number, a sequence. Which fields you choose also decides how the id sorts, and sorting decides how an index behaves. Random ids are easy to generate anywhere and scatter writes across an index. Time-prefixed ids sort roughly by creation time, so inserts land at the right edge. The price of time-prefixed ids is a dependence on a clock, and a creation time that anyone can read.
Three examples run through the lesson, each labelled:
- Minimal example: a Java lab that builds UUIDv4, UUIDv7 and ULID from their specifications and inserts them into a model of an index.
- Realistic example: ShopStream's order-event table, written by several services, in "In practice".
- Failure example: one application server with a wrong clock, and what it does to ordering.
What an id must do
| Property | Question it answers |
|---|---|
| Uniqueness without coordination | Can any node make an id with no call to a central service? |
| Order | Does sorting by id roughly sort by creation time? |
| Size | How many bytes does it add to every row, index entry and foreign key? |
| Opacity | Does it reveal when it was made, or how many exist? |
| Locality | Do consecutive inserts touch the same few index pages? |
No format is best at all five, which is why the formats below exist.
The formats, bit by bit
All four are fixed-width. RFC 9562 defines UUID versions 4 and 7; the ULID specification defines ULID; the original Snowflake README defines the 64-bit layout.
| Format | Size | Layout | Text form | Order |
|---|---|---|---|---|
| UUIDv4 | 128 bits | 122 random bits, 4 version bits (0100), 2 variant bits (10) |
32 hex digits in 8-4-4-4-12 groups, 36 characters with hyphens | None |
| UUIDv7 | 128 bits | 48-bit Unix millisecond timestamp, version 0111, 12 random bits (rand_a), variant 10, 62 random bits (rand_b) |
Same as UUIDv4 | By millisecond |
| ULID | 128 bits | 48-bit millisecond timestamp, 80 random bits | 26 characters of Crockford base32 | By millisecond; strict within one millisecond if generated monotonically |
| Snowflake-style | 64 bits | In the original: 41 bits of time (custom epoch), 10 bits machine id, 12 bits sequence | A number | By millisecond across machines, strict per machine |
Some details that matter in practice:
- UUIDv4 needs only a good random source. The RFC's section 5.4 sets the version bits to
0100and the variant bits to10. - UUIDv7 (section 5.7) puts the timestamp first, so a byte-wise compare sorts by time. The
rand_afield is 12 bits of pseudorandom data "to provide uniqueness", and/or optional constructs for additional monotonicity. Section 6.2 lists optional ways to make ids in the same millisecond increase: a counter inrand_a, a monotonic random part, or sub-millisecond clock bits. Plain UUIDv7 has none of them. - ULID uses the same idea with a different text form. The specification's alphabet is
0123456789ABCDEFGHJKMNPQRSTVWXYZ, which leaves out I, L, O and U, and it is case insensitive. Because 26 characters hold 130 bits, the first character is 0 to 7, and the largest ULID is7ZZZZZZZZZZZZZZZZZZZZZZZZZ. For ids created in the same millisecond, the specification describes incrementing the random part by one in its least significant bit, and failing if it overflows. - Snowflake-style ids are a design, not a library. The README gives 41 bits of time ("69 years" with a custom epoch), 10 bits of machine id (up to 1,024 machines) and 12 bits of sequence, "with protection to avoid rollover in the same ms". It says Snowflake refuses to generate ids while the clock runs backwards. The worker number is the coordination cost: two machines with the same worker id can collide, so something must hand out worker ids. The repository already has a lab for it,
examples/backend/labs/snowflake-id, with a README and captured output (examples/golden/snowflake-id.txt). This lesson does not rebuild it; use it for sequence overflow and clock-backwards handling.
Figure 1. Three places to make an id, one index to feed. How to read this: the app server can build a time-ordered id with no network call. A Snowflake-style id adds a registry that gives each worker a unique number. A PostgreSQL 18 column default can build a UUIDv7 inside the database. Whatever the source, the primary-key index receives the ids in the order they arrive.
Why order decides index behaviour
A B-tree primary-key index keeps its entries sorted in fixed-size pages. An insert goes to the one page whose range holds the key. If the page is full, it splits.
- With random keys, each insert lands on an unpredictable page. Over time every page is a target, so all of them must be in memory to avoid reads from disk, and splits happen in the middle of the tree and leave pages partly empty.
- With time-ordered keys, nearly every insert lands on the last page. The pages behind it are no longer touched by new rows.
RFC 9562 says the same in its own words: UUIDs that are not time ordered, such as UUIDv4, "have poor database-index locality", and time-ordered monotonic UUIDs "benefit from greater database-index locality". The PostgreSQL documentation adds one detail about fill: for a B-tree, leaf pages are filled to the fillfactor (default 90) during initial builds and "also when extending the index at the right (adding new largest key values)", and pages that later become full are split, "leading to fragmentation of the on-disk index structure".
Figure 2. The insert path the lab models. How to read this: random keys travel the left branches (miss, then middle split) again and again. Time-ordered keys take the right-edge path, and the page they touch is the one just created.
Build it: three formats and one index model
Goal. Build UUIDv4, UUIDv7 and ULID from their layouts, check them against the specifications, and see how each behaves when inserted in creation order into a model of a B-tree's leaf level.
Setup. From the repository's examples/backend folder:
cd examples/backend && ./gradlew :labs:uuid-ulid-index-locality:test
./gradlew -q :labs:uuid-ulid-index-locality:run
The first command runs the 19 tests, including one that rebuilds the RFC's example UUIDv7 017f22e2-79b0-7cc3-98c4-dc0c0c07398f from its fields, one that checks that ULID text sorts like the binary value, and one that checks the largest ULID. The code is in examples/backend/labs/uuid-ulid-index-locality/src/main/java/buildingai/labs/uuidulid/.
How the model works. Each generator takes a clock and a seeded random source, so every run is the same. The workload is 100,000 ids at 10 per simulated millisecond, inserted in creation order. LeafIndexModel is an ordered list of pages with 100 keys each and a buffer pool that holds 64 pages (a least-recently-used cache). A touch outside the pool is a miss. A full page splits in half, except at the right edge, where the old page keeps 90 percent, which follows the PostgreSQL fillfactor statement above. The model has no inner pages, deletes, concurrency or log, and its numbers say how the formats compare here, not how fast any database is. One comparison detail matters: ids are compared as unsigned numbers, because the variant bits make the low half negative as a signed long.
Expected output, captured from a real run (Java 25.0.3, 2026-11-08):
UUIDv4, UUIDv7 and ULID: layout, ordering and index locality (seeded run, simulated clock).
1. One id of each kind at t=1700000000000 ms (2023-11-14T22:13:20Z)
UUIDv4 bdd73226-2feb-4e95-a8ef-e333b266f103 version 4, variant bits 10
UUIDv7 018bcfe5-6800-7f88-9cde-98852e60034b version 7, variant bits 10
ULID 01HF7YAT00AH9S1Y1TZ4B7PR92 (26 characters, the first 10 are the time)
ULID (monotonic) 01HF7YAT009BBFPRJBV67JQSP4 (26 characters, the first 10 are the time)
RFC 9562 A.6 example rebuilt from its fields: 017f22e2-79b0-7cc3-98c4-dc0c0c07398f (version 7)
2. Creation order versus sort order (100000 ids, 10 per millisecond)
generator next id is larger time prefix
UUIDv4 50.0% none
UUIDv7 54.9% 48-bit milliseconds
ULID 55.0% 48-bit milliseconds
ULID (monotonic) 100.0% 48-bit milliseconds
3. Inserting 100000 ids in creation order into the leaf-level model (100 keys per page, 90% fill at the right edge, buffer pool of 64 pages)
generator leaf pages avg fill splits (rightmost) buffer misses misses/insert
UUIDv4 1427 70.1% 1426 ( 0) 81115 0.811
UUIDv7 1111 90.0% 1110 ( 1110) 1 0.000
ULID 1111 90.0% 1110 ( 1110) 1 0.000
ULID (monotonic) 1111 90.0% 1110 ( 1110) 1 0.000
4. The same workload with a bigger buffer pool: misses per insert
buffer pages 16 64 256 1024 2048
UUIDv4 0.936 0.811 0.507 0.043 0.000
UUIDv7 0.000 0.000 0.000 0.000 0.000
| Section | What it shows |
|---|---|
| 1 | One id of each kind, and the RFC example rebuilt from its fields. |
| 2 | The share of ids larger than the previous one. UUIDv4 is 50 percent: no order. Plain UUIDv7 and ULID are about 55 percent, because ids from different milliseconds increase and ids inside one millisecond are in random order. A monotonic ULID is 100 percent. |
| 3 | The index. Random keys need 1,427 leaf pages at 70.1 percent fill and miss the buffer on 0.811 inserts out of 1. Time-ordered keys need 1,111 pages at 90.0 percent and miss once in the whole run: every split is at the right edge. |
| 4 | Buffer sweep. Random keys only stop missing when the pool approaches the number of leaf pages (0.043 at 1,024 pages, zero at 2,048). Time-ordered keys never miss: their working set is one page. |
The ordering inside one millisecond does not matter to the index: the keys in one millisecond all land on the same last page. It matters to anything that expects "the next id is larger", which is why the monotonic ULID and the counter methods in RFC 9562 section 6.2 exist.
Break it. In LeafIndexModel.split, replace the keep computation with int keep = page.keys.size() / 2; so right-edge splits also halve. Time-ordered keys now leave pages half empty, and two tests fail: ascendingInsertsFillPagesToTheRightmostFillAndNeverSplitInTheMiddle and theWholePipelineGivesUuidV7AndUlidFarBetterLocalityThanUuidV4.
Stretch. Add the rand_a counter from RFC 9562 section 6.2 so plain UUIDv7 is strictly increasing. Give the generators eight nodes whose clocks differ by a few milliseconds. Add a Snowflake column from the snowflake-id lab.
Clocks, leaks and the rest of the trade-offs
Clocks. UUIDv7, ULID and Snowflake all read a clock. If it moves backwards, ids can sort before ones already issued, or collide if a sequence restarts. The RFC says implementations "need to determine how to handle" a clock moving backwards and recommends checking that each new id is greater than the previous one (section 6.2, monotonic error checking). The Snowflake README's answer is to refuse until the clock passes the last time used. Different machines also disagree by some milliseconds, so "sorted by id" means "sorted by creation time within clock skew". Never use a time-ordered id as a proof of what happened first across machines; use a version number or a log position for that, as the IDs and clocks lesson explains.
Leaks. A timestamp prefix tells anyone who sees the id when it was made. PostgreSQL even ships uuid_extract_timestamp() for versions 1 and 7, with the caveat that the result is "not necessarily exactly equal to the time the UUID was generated". And RFC 9562 section 8 says implementations "SHOULD NOT assume that UUIDs are hard to guess" and that they "MUST NOT be used as security capabilities" (identifiers whose mere possession grants access). If an id appears in a URL, keep a separate unguessable public identifier.
PostgreSQL 18. The documentation lists gen_random_uuid() and uuidv4() for a random UUID, and uuidv7() for a time-ordered one, with an optional shift interval that moves the timestamp; its timestamp is "UNIX timestamp with millisecond precision + sub-millisecond timestamp + random". At the time of writing that makes a time-ordered default available in the database itself.
| Option | Gain | Cost | When it is the wrong choice |
|---|---|---|---|
| UUIDv4 | Simplest; needs only a random source; no clock | Scatters the index; 16 bytes | A write-heavy table whose index outgrows memory |
| UUIDv7 | No coordination; time-ordered; standard; database support | 16 bytes; leaks creation time; clock dependence | Ids that must not reveal timing, with no separate public id |
| ULID | Sortable 26-character text; monotonic mode | Specified on GitHub rather than by the IETF; 16 bytes; clock dependence | A team that wants the UUID type and tooling |
| Snowflake-style | 8 bytes; strictly increasing per worker | Worker-id registry; clock-backwards handling; a custom design | You cannot run worker leases safely |
| Database sequence | 8 bytes; no clock | A central counter; breaks across shards | Multi-writer or sharded systems |
Figure 3. Skew reorders ids across servers. How to read this: each server stamps its own clock. The order of ids is the order of the stamps, not of real time, so a few milliseconds of skew can swap two rows created within those milliseconds.
In practice: ShopStream's order-event table
This is the realistic example. It is an educational scenario on the course's running example, ShopStream, and nothing in it describes a real company. Every figure is illustrative or taken from the shared ShopStream numbers.
This is a realistic scenario, not a case history.
The situation
ShopStream plans 500,000 orders per day (the shared planning number). Orders themselves use Snowflake-style ids, chosen in Stage 4. Every order produces several events (placed, paid, picked, out for delivery and so on), written by different services into one PostgreSQL table, order_event. Assume, illustratively, eight events per order, about 4,000,000 rows a day. The services generate event ids themselves with a random UUID. Insert latency was fine in the first weeks and has crept up as the primary-key index has grown past what stays in memory.
Starting architecture
Figure 4. The design under discussion. How to read this: three services each create their own ids, so there is no single generator to make ids sequential. The table's primary-key index receives them in random order (amber).
Options considered
| Option | Behaviour | Cost | Risk |
|---|---|---|---|
| A. Keep UUIDv4, add memory | The index stays in memory a while longer | Money | Delays the problem; the index keeps growing |
| B. Database sequence | Perfectly ordered, 8 bytes | A central counter every service must call | A bottleneck and a dependency for every insert |
| C. Snowflake-style ids in each service | 8 bytes, ordered | A worker-id registry and clock handling in three services | More to operate than the problem needs |
| D. UUIDv7 default in the database | Ordered inserts, no coordination | 16 bytes, leaks creation time | Skew between services makes order approximate |
The decision
Option D, using the PostgreSQL 18 default. A sketch, not run here:
CREATE TABLE order_event (
id uuid PRIMARY KEY DEFAULT uuidv7(),
order_id bigint NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL
);
Because the database creates the id, there is one clock, and the services no longer decide the format. Ordering across events of one order comes from an explicit version column, not from the id, because id order is only creation order within clock skew. Ids stay internal; customers see order codes.
Rollout and verification
Change the default for new rows only: existing random ids stay where they are, and only new inserts become right-edge appends. Verify by comparing the index's on-disk size growth per million rows before and after, and the insert latency percentile at peak hour. Roll back by restoring the old default; nothing about existing rows changes.
The 2 a.m. failure
The page reads: an export job that pages through events "by id" is skipping rows. First checks: do the skipped rows come from a service that sets its own id (a client that still generates ids locally), and does any server's clock differ from the database's? One batch tool still generated UUIDv7 locally from a host whose clock was an hour ahead, so its ids sort an hour into the future and fall outside the export's cursor. Mitigation: re-export by a column that records the insert time. Lasting fix: have only the database create ids, alert on host clock offset, and page exports by a timestamp column with the id as a tiebreaker.
Architect's notes
- Choose per table, not per company. A small lookup table can use anything; the large write-heavy table decides.
- Do not use an id as a timeline. Use a version column or a log offset when order matters across machines.
- Keep a public id separate when ids reach URLs or support tools.
- Decide who generates. One generator (the database) means one clock; many generators mean skew and more things to monitor.
- Size adds up. 16 bytes in a primary key is repeated in every secondary index and foreign key.
Common mistakes
- Random ids as the key of a huge insert-heavy table, then blaming the database for slow inserts.
- Treating a UUIDv7 as unguessable. The RFC says not to assume that.
- Assuming strict order within a millisecond. Plain UUIDv7 and a non-monotonic ULID do not give it.
- Comparing UUIDs as signed numbers. The variant bits make the low half negative; compare bytes or unsigned values.
- Ignoring the clock when ids carry time: no backwards-clock rule, no skew alert.
Key takeaways
- An id is unique by construction, from random bits, time, worker and sequence fields.
- UUIDv4 is random and scatters an index; UUIDv7 and ULID put a millisecond timestamp first and append at the right edge.
- In the lab model, 100,000 random keys needed 1,427 leaf pages at 70.1 percent fill and missed the buffer on 0.811 inserts each; time-ordered keys needed 1,111 pages at 90.0 percent and almost never missed.
- Snowflake ids are 64 bits and need worker ids and a clock rule; the repository's snowflake-id lab covers them.
- Time-ordered ids sort by creation time only within clock skew, and they leak the creation time.
- PostgreSQL 18 has
uuidv7(), which makes a time-ordered default available in the database.
Practice exercises
Exercise 1 (30 min). Run the lab and explain, in two sentences, why the "next id is larger" column is about 55 percent for UUIDv7 even though every id from a later millisecond is larger.
Exercise 2 (1 hour). Do the stretch that adds a counter to UUIDv7 and show the column rises to 100 percent without changing the index numbers.
Exercise 3 (1 hour). Change the buffer sizes in section 4 to a pool as large as the number of pages and explain what the result says about "random keys are slow".
Exercise 4 (2 hours). Run the Snowflake lab, then write a one-page comparison of its 64-bit ids with UUIDv7 for a table of your choice, including the operational duties each adds.
Related reading
- IDs, Clocks and Ordering: the overview of id schemes, wall clocks and logical clocks.
- Storage Engines: B-trees, pages and why splits cost.
- Databases Basics: keys and indexes.
- Sharding: why a central counter stops working across shards.
- ShopStream Stage 4: scale writes: where ShopStream chose Snowflake-style order ids.
- CAP Theorem and PACELC: why coordination is not free.
Official documentation
- IETF, RFC 9562: Universally Unique IDentifiers (UUIDs): layouts of versions 4 and 7, monotonicity, index locality, security considerations.
- The ULID specification: layout, Crockford base32, monotonicity.
- PostgreSQL 18 documentation, UUID functions:
uuidv7(),uuidv4()anduuid_extract_timestamp(). - PostgreSQL 18 documentation, CREATE INDEX: the B-tree
fillfactorparameter. - Twitter archive, Snowflake README (snowflake-2010 branch): 41-bit time, 10-bit machine id, 12-bit sequence, clock-backwards protection.