Skip to content

Unique ID Generator System Design: Snowflake, UUIDv7 and ULID Compared

How to generate unique IDs in a distributed system: sequences, UUIDv4, UUIDv7, ULID and Snowflake compared, then a Snowflake-style generator designed step by step.

· Founder & developer

Published

Architecture diagram. Payment and order services call NextId() on an ID service with three instances, worker IDs 0 to 2, which lease IDs from an etcd cluster and sync time from NTP. The order service inserts rows into Postgres. A bar splits 64 bits as 1, 41, 10, 12.

Your order service has used a Postgres BIGSERIAL key for years. Then the orders table is split across four shards, each shard's sequence hands out order 10452, and support finds two customers behind one order number. Or the mobile app needs an ID before it can reach the server at all.

IDs stop being free once more than one machine creates them. The options trade size, sort order, coordination per ID and how kindly they treat a B-tree index. This post compares sequences, ticket servers, UUIDv4, UUIDv7, ULID and Snowflake-style IDs, then designs a Snowflake-style generator: bit layout, worker ID leases, clock jumps and collision math.

Requirements for distributed IDs

  • Unique, forever. Across shards, regions and restarts.
  • Roughly sorted by time, so WHERE id > ? pagination and time-ordered indexes work. Twitter's original Snowflake promised order across machines within one second.
  • Compact. 64 bits fit a BIGINT; 128 bits double every key.
  • No coordination per ID. Snowflake's target was at least 10,000 IDs per second per process, each within 2 ms.
  • Safe to expose, leaking no more than you intend.

Six ways to generate IDs

Approach Size Sort order Coordination B-tree inserts
Database sequence 64 bits Increasing Every ID from one database Right edge
Ticket server 64 bits Increasing per server A call per ID or per range Right edge
UUIDv4 128 bits None None Random pages
UUIDv7 128 bits By millisecond None Near the right edge
ULID 128 bits By millisecond None Near the right edge
Snowflake-style 64 bits By millisecond, roughly A worker ID per generator Near the right edge

A database sequence is right while one database writes every row and breaks once several databases share an ID space. It leaves gaps, since nextval is never rolled back, and in PostgreSQL a CACHE above 1 keeps values distinct but out of order across sessions. Sequential order numbers also reveal your order volume.

A ticket server centralizes the sequence. Flickr's MySQL ticket servers were two machines, one issuing odd and one even numbers, with clients alternating between them. Leasing blocks of IDs removes the call per ID, as in the URL shortener design.

UUIDv7, ULID and Snowflake put a timestamp first, so IDs created together sort together; only Snowflake fits in 64 bits, paying with worker IDs and a clock dependency.

The Snowflake bit layout

Bits Field Capacity
1 Sign bit, always 0 IDs stay positive as signed 64-bit integers
41 Milliseconds since a custom epoch 2^41 ms, about 69.7 years
10 Worker ID 1,024 generators
12 Sequence within the millisecond 4,096 IDs per millisecond per worker

Twitter's 2010 release used this split with an epoch in November 2010, so its IDs run out in July 2080. From the Unix epoch, 41 bits would run out in September 2039, so start the epoch near your launch. Each instance keeps its last millisecond and a counter:

// Next is called with g.mu held.
func (g *Generator) Next() (int64, error) {
	if time.Now().After(g.leaseDeadline) {
		return 0, ErrLeaseLost // another instance may own this worker ID now
	}
	now := time.Now().UnixMilli()
	if now < g.lastMs {
		if g.lastMs-now > 5 {
			return 0, ErrClockMovedBack
		}
		now = g.waitUntil(g.lastMs) // small step back: wait it out
	}
	if now == g.lastMs {
		g.seq = (g.seq + 1) & 0xFFF // 12 bits
		if g.seq == 0 {             // 4,096 IDs used in this millisecond
			now = g.waitUntil(g.lastMs + 1)
		}
	} else {
		g.seq = 0
	}
	g.lastMs = now
	return (now-epochMs)<<22 | g.workerID<<12 | g.seq, nil
}

One worker issues up to 4.1 million IDs per second; the RPC around it is the real limit, so let clients fetch IDs in batches. IDs pass 2^53 about 25 days after the epoch, beyond JavaScript's Number.MAX_SAFE_INTEGER, so send them as JSON strings, as the X and Discord APIs do.

The split is a budget. Discord uses 42 bits of time and splits the node bits into worker and process. Instagram generated IDs inside each PostgreSQL shard with a 13-bit shard ID, which helps when you shard a database. Sonyflake counts 10 ms units to last 174 years, at 256 IDs per 10 ms per instance.

Assigning worker IDs

Snowflake needs no coordination per ID, only per process: two live generators with one worker ID can issue the same ID.

  1. Static configuration. Each host gets a WORKER_ID. Fine for a fixed fleet; one copied config file creates duplicates.
  2. StatefulSet ordinals. Kubernetes numbers a StatefulSet's pods from 0 and labels each with apps.kubernetes.io/pod-index (stable since v1.32), which the downward API passes in. Ordinals are unique only within one StatefulSet in one cluster, and force-deleting a pod can leave two pods with the same ordinal.
  3. Leases in etcd or ZooKeeper. At startup the instance claims the lowest free key, such as /idgen/workers/17, with a transaction that succeeds only if the key is absent, on a 10-second lease renewed every 3 seconds. If the instance dies, the key expires. Twitter's 2010 Snowflake used a ZooKeeper ephemeral node and refused to start if its clock was over 10 seconds off its peers' mean.
env:
  - name: WORKER_ID
    valueFrom:
      fieldRef:
        fieldPath: metadata.labels['apps.kubernetes.io/pod-index']

Leases need two rules. Stop issuing IDs once the last successful keepalive is older than the granted TTL minus a margin, so a paused or partitioned instance stops before another can claim its ID. And save a high-water mark: every few seconds, store a timestamp slightly ahead of the clock in a separate key, never issue IDs past it, and make the next holder wait until its clock passes it. etcd keeps both consistent with Raft.

When the clock moves backwards

NTP daemons usually slew the clock, running it slightly fast or slow, but step it for large offsets: ntpd steps above 128 ms by default, and under chrony's default leap second mode the kernel steps the clock back one second for an inserted leap second. The chrony documentation advises stepping only at boot:

makestep 1.0 3      # step (if off by over 1 s) only in the first three updates
leapsecmode slew    # absorb a leap second by slewing, not by stepping back

For a step that gets through, pick a rule:

Strategy What the generator does Cost
Fail Rejects requests until the clock passes last_ms Errors for as long as the step lasts
Wait Sleeps until the clock passes last_ms Fine for a few ms; long steps stall every caller
Logical clock Keeps counting from last_ms Never blocks; timestamps run ahead of real time

Twitter's original failed. Waiting a few milliseconds, then failing with an alert, is a sensible default. Timing from CLOCK_MONOTONIC, which never goes backwards, protects a running process; the high-water mark covers restarts.

UUIDv7 and B-tree indexes

RFC 9562, published in May 2024, replaced RFC 4122 and defined UUIDv7: a 48-bit Unix timestamp in milliseconds, 4 version bits, 2 variant bits and 74 bits for randomness, or for a counter when you need strict order within a millisecond. The timestamp lasts until the year 10889.

The RFC warns that UUIDs without time order have poor index locality, with effects on B-trees that can be dramatic. In PostgreSQL, new v7 keys go to the rightmost leaf page, and splits there leave pages 90% full, the default B-tree fillfactor. Random v4 keys hit any page: the whole index becomes the working set, full pages split, and after each checkpoint the first change to each page writes the full page to the WAL. MySQL's InnoDB also copies the primary key into every secondary index, so a 16-byte key grows them all.

PostgreSQL 18 added uuidv7() in September 2025. Store UUIDs as 16-byte binary, not 36 characters of text. And v7 reveals its creation time; the RFC recommends v4 wherever an ID plays a security role.

ULID

The ULID specification has the same shape: a 48-bit millisecond timestamp and 80 random bits. Its 26-character Crockford base32 text, without I, L, O and U, sorts correctly as a string and reads well in URLs. Monotonic mode adds 1 within a millisecond, which keeps order but makes the next value guessable. ULID is a community spec; for database keys, UUIDv7 offers the same with native support.

How likely is a collision?

For n IDs with b random bits, the chance of any collision is about n² / 2^(b+1). Time-prefixed IDs can only collide within one millisecond, so the rate matters, not the total. At 1 million IDs per second for a year, spread evenly:

Scheme Random bits Can collide with Chance in one year
UUIDv4 122 Any other ID About 1 in 10 billion
ULID 80 IDs from the same millisecond About 1 in 77 million
UUIDv7 74 IDs from the same millisecond About 1 in 1.2 million
Snowflake 0 Same worker ID and millisecond Zero, while worker IDs are unique and clocks move forward

Random schemes fail with a small, calculable probability. Snowflake fails only through operations, but then with certainty. Keep a unique constraint either way.

The Snowflake-style ID generator diagram

Architecture diagram. Payment and order services call NextId() on an ID service with three instances, worker IDs 0 to 2, which lease IDs from an etcd cluster and sync time from NTP. The order service inserts rows into Postgres. A bar splits 64 bits as 1, 41, 10, 12.

18 components · 5 connections

Open in Archboard
Diagram contents
  • ID service · 3 replicas
  • id-gen-0 · worker ID 0
  • id-gen-1 · worker ID 1
  • id-gen-2 · worker ID 2
  • Payment service
  • Order service
  • orders · id BIGINT PRIMARY KEY
  • etcd cluster
  • NTP servers
  • 0
  • 41 bits · milliseconds since the custom epoch 2^41 ms ≈ 69.7 years
  • 10 bits · worker ID 1,024 instances
  • 12 bits · sequence 4,096 IDs per ms
  • NextId()
  • INSERT row with the new id
  • lease worker ID
  • time sync
A Snowflake-style ID service: three instances lease worker IDs from etcd, sync time from NTP and answer NextId() from memory. Unique worker IDs and a clock that never runs backwards keep every 64-bit ID unique; the bar shows the bit layout.

How to read it:

  • Clients call NextId() on any of three instances, which answer from memory: the last millisecond and a 12-bit counter.
  • etcd holds each worker ID on a 10-second lease, plus the saved timestamps.
  • NTP keeps the clocks close (dashed edge); chrony steps only at boot.
  • The order service writes rows to Postgres, where rising IDs land on the rightmost B-tree page.
  • The bar shows the 64 bits to scale; the Markdown block lists the settings.

Drawing ID generation clearly

  • Draw the bit layout to scale. It shows the budget at a glance.
  • Show where worker IDs come from, with the lease TTL. Duplicates start there.
  • Draw the clock as a dependency. The generator is only as correct as its clock.
  • Write the failure rules on the canvas: clock moved back, lease lost, restart. The system design diagram example puts numbers next to the boxes the same way.

Build it in Archboard

  1. Sign in with Google or GitHub and create a diagram.
  2. Add a Frame from the Core pack for the ID service, with three amber Container nodes from the C4 Model pack inside: id-gen-0 to id-gen-2, one worker ID each.
  3. Add two Container nodes on the left for the payment and order services, and a Postgres node from the Data pack below them.
  4. On the right, add a violet Container for etcd, a blue Server from the Network pack for NTP and a Markdown block from the Core pack with the settings.
  5. Connect them: NextId() from both clients to the frame, INSERT to Postgres, the lease to etcd and a dashed time sync edge from NTP.
  6. Below, add four rectangle Shape nodes from the Core pack, sized 1 : 41 : 10 : 12, for the bit layout, and a few Core Text notes.

A Mermaid flowchart of the design becomes an editable first draft with the Mermaid converter. The example uses 14 diagram nodes, within the Free plan.

FAQ

What is a Snowflake ID? A 64-bit ID built from a timestamp, a worker ID and a per-millisecond sequence, introduced by Twitter in 2010. Generators work without talking to each other, and IDs sort roughly by creation time.

Is UUIDv7 better than ULID? Both hold a 48-bit millisecond timestamp and random bits. UUIDv7 is an IETF standard with database support, such as uuidv7() in PostgreSQL 18; ULID has a shorter text form. For database keys, prefer UUIDv7.

Should I use UUIDv4 or UUIDv7 as a primary key? UUIDv7 for most tables: new values sort after old ones, so inserts stay near the right edge of the index. Use UUIDv4 where an ID must not reveal its creation time.

How many IDs per second can a Snowflake generator produce? 4,096 per millisecond per worker, about 4.1 million per second, or 4.2 billion across 1,024 workers. The network call around the generator is usually the real limit.

What happens when the clock goes backwards? The generator must not reuse a millisecond it may have used. It can fail until the clock catches up, wait, or keep a logical clock that runs ahead of real time; Twitter's original failed.

Start free — one seat, no credit card.

Related posts

← Back to blog

Open a canvas in a minute

Sign in with Google or GitHub. Free for a single seat. Upgrade when the team joins.