Skip to content

    System Design

    Unique ID Generation: UUIDv4, UUIDv7, ULID and Snowflake Compared

    Compare UUIDv4, UUIDv7, ULID and Snowflake ids by size, ordering, coordination and index locality, with a Java lab that shows what random keys do to an index.

    By Published 15 min read

    Share

    Standard lessonintermediateJava labBackend System Design

    Before this: IDs, Clocks and Ordering

    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 0100 and the variant bits to 10.
    • UUIDv7 (section 5.7) puts the timestamp first, so a byte-wise compare sorts by time. The rand_a field 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 in rand_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 is 7ZZZZZZZZZZZZZZZZZZZZZZZZZ. 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.
    Where an id can be createdApp servers to PostgreSQL (insert with an id); Worker-id registry to App servers (worker number); PostgreSQL to Primary key (one entry per row)App servers: UUIDv7 or ULID, no callsApp serversUUIDv7 or ULID,no callsWorker-id registry: Snowflake onlyWorker-idregistrySnowflake onlyPostgreSQL: uuidv7() defaultPostgreSQLuuidv7() defaultPrimary key: B-tree indexPrimary keyB-tree indexinsert with an idworker numberone entry per row

    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".

    flowchart TD accTitle: What happens when a key is inserted into a B-tree leaf level accDescr: The index finds the leaf page for the key. If the page is not in memory it is read, which is a buffer miss. If the page has room the key is inserted. If it is full and the key is larger than every key, a new page is started and the old page keeps about ninety percent. Otherwise the page splits in half. K["New key"] --> F["Find the one leaf page for this key"] F --> B{"Page in the buffer pool?"} B -->|"no"| M["Read it: a buffer miss"] B -->|"yes"| R{"Room in the page?"} M --> R R -->|"yes"| I["Insert the key"] R -->|"no"| L{"Is the key larger than every key?"} L -->|"yes"| A["Right-edge split: old page stays about 90 percent full"] L -->|"no"| H["Middle split: half the keys move to a new page"]

    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
    sequenceDiagram accTitle: A skewed clock reorders ids from two servers accDescr: Server A's clock runs five milliseconds ahead of server B's. A creates an id at real time 100 and stamps it 105. B creates an id at real time 102 and stamps it 102. Sorting by id puts B's later-created row before A's. participant A as App server A (clock +5 ms) participant B as App server B (clock correct) participant I as Index Note over A,B: real time 100 ms A->>I: insert, id stamped 105 Note over A,B: real time 102 ms B->>I: insert, id stamped 102 Note over I: ids sort B then A, although A was created first

    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

    ShopStream order events before the changeOrder service to order_event (id from UUIDv4); Payment service to order_event; Delivery service to order_eventOrder serviceOrder servicePayment servicePaymentserviceDelivery serviceDeliveryserviceorder_event: PK: random UUIDorder_eventPK: random UUIDid from UUIDv4

    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.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments