Generating unique IDs in a distributed system
Open in the interactive app: practice, quiz, mock interview →In one minute
- Many designs need IDs that are unique across many machines without a round trip to a central counter on every write: tweets, messages, orders, short URLs, events.
- The main families are: a central sequence (simple, a bottleneck), block allocation from a central counter (amortised coordination), random IDs such as UUIDv4 (no coordination, poor index locality), and time-ordered IDs such as Snowflake, UUIDv7 and ULID (no per-ID coordination and roughly sortable by creation time).
- Snowflake-style IDs pack a millisecond timestamp, a worker ID and a per-millisecond sequence into 64 bits. Their two hard problems are assigning unique worker IDs and surviving clocks that move backwards.
- Time-ordered keys insert at the right edge of a B-tree, which keeps indexes compact and cache-friendly; random keys scatter inserts across the whole index.
- In interviews, state the properties you need (size, sortability, opacity, throughput) before picking a scheme.
How it works
When the pattern shows up in interviews
"Design a URL shortener" (short codes), "Design Twitter" (tweet IDs that sort by time), "Design WhatsApp" (message IDs for ordering and dedupe), sharded databases where auto-increment no longer works, and any idempotency key. The interviewer is checking whether you can list the required properties and match a scheme to them, and whether you know the failure modes: duplicate worker IDs, clock rollback, and IDs leaking business volume.
Properties to decide first
- Uniqueness scope: global, per tenant or per shard.
- Size: 64 bits fits a
BIGINTand is cheap to index; 128 bits (UUIDs) doubles key size in every index that stores it. Store UUIDs as 16 bytes of binary, as RFC 9562 recommends, not as 36-character strings: Percona's 1-billion-row example spent 216 GB onchar(36)key copies alone (Percona). - Width headroom: a 32-bit signed key tops out at 2,147,483,647. Pick 64 bits for any table that grows; widening a hot column later means a long migration.
- Ordering: none, roughly by time (k-sorted), or strictly increasing per entity.
- Opacity: should outsiders be unable to guess neighbours or infer how many orders you take per day?
- Throughput and availability: can ID generation tolerate a coordinator outage?
Central sequence and block allocation
A single database sequence is strictly increasing and trivially unique, but every insert depends on one node. Two common fixes: give each of N databases a different offset with a step of N, or hand out blocks. MySQL's auto_increment_increment and auto_increment_offset produce the series offset + N × increment and apply to every table on the server, not one (MySQL docs). With block allocation, a service atomically bumps a counter by 1,000 (UPDATE counters SET next = next + 1000 WHERE name = 'order' RETURNING next) and then issues those 1,000 IDs from memory. A crash wastes the rest of the block, which is fine because uniqueness, not density, is the requirement. Flickr's "ticket servers" are the classic example of a dedicated sequence service: each ID is a REPLACE INTO on a one-row MySQL table followed by LAST_INSERT_ID(), and two servers with increment 2 and offsets 1 and 2 issue odd and even IDs so either can fail without coordination (Flickr).
Random IDs
UUIDv4 has 122 random bits, so collisions are practically impossible without any coordination. The costs are 16 bytes per key, no time ordering, and scattered B-tree inserts. RFC 9562 still says to use UUIDv4 when an ID must be unguessable for security purposes, because time-based versions reveal creation order (RFC 9562, section 8).
Time-ordered IDs
Snowflake, published by Twitter in 2010, uses 1 unused sign bit, 41 bits of milliseconds since a custom epoch (about 69 years of range), 10 bits of machine ID (1,024 machines), and 12 bits of sequence (4,096 IDs per millisecond per machine). Its stated goal was IDs "k-sorted" within 1 second, aiming for tens of milliseconds, with no coordination between machines (Snowflake README); the original Scala service is archived, but the format lives on. IDs from one worker strictly increase; IDs across workers are ordered to within clock skew. Variants move the boundaries: Instagram generated IDs inside Postgres with 41 bits of time, 13 bits of logical shard ID, and 10 bits from a per-shard sequence (Wikipedia); Discord uses 42 bits of time from a 2015 epoch, 5 bits of worker, 5 bits of process and a 12-bit increment (Discord docs).
UUIDv7, standardised in RFC 9562 (May 2024, which obsoletes RFC 4122), puts a 48-bit Unix millisecond timestamp in the top bits and fills the rest with version bits and randomness, giving time ordering with no worker IDs. The RFC says new systems should prefer UUIDv7 over the older time-based v1 and v6 (RFC 9562). Databases now generate it natively: PostgreSQL 18 (September 2025) added uuidv7() (PostgreSQL 18 release notes). ULID is a similar 128-bit design (48 bits of time, 80 random) encoded as 26 Crockford base-32 characters; its monotonic mode increments the random part within a millisecond and throws an error if that overflows (ULID spec).
Short codes
A URL shortener wants 7-character codes. Base62 over a 64-bit counter gives about 3.5 trillion codes in 7 characters (62^7). A raw counter makes codes guessable, so either encode a bijective scramble of the counter (a small Feistel network or multiply by an odd constant mod 2^n) or draw random codes and retry on the rare collision through a unique index. The system-design-primer's Pastebin answer takes the hash route: Base62 of an MD5 over IP and timestamp, truncated to 7 characters, regenerated if it already exists.
Down the stack
Layer 1The contract
Every issued ID is unique within its scope forever, including across restarts and failovers. For Snowflake: IDs from one worker strictly increase; IDs from different workers are ordered by timestamp to within clock skew plus one millisecond; the generator either returns an ID or a clear error (clock moved backwards, sequence exhausted), and never a duplicate. Generation is local and takes well under a microsecond.
Layer 2The mechanism
A Snowflake generator keeps last_ms and sequence. On each call it reads the clock. If now > last_ms, it resets the sequence to 0. If equal, it increments; on overflow past 4,095 it waits for the next millisecond. If now < last_ms, the clock went backwards, so it waits or refuses, because continuing would reissue timestamps it already used. The ID is (now - epoch) << 22 | worker << 12 | sequence. Worker IDs must be unique among live generators: assign them from a lease in ZooKeeper or etcd (an ephemeral node or a key with a TTL that the process renews), from a StatefulSet pod ordinal, or from a database table claimed at startup. A UUIDv7 layout is: 48 bits unix_ts_ms, 4 bits version (0111), 12 bits rand_a, 2 bits variant, 62 bits rand_b; RFC 9562 allows using rand_a as a sub-millisecond counter for monotonicity within one generator.
Layer 3The machine
Reading the clock with clock_gettime(CLOCK_REALTIME) goes through the vDSO without a syscall and costs tens of nanoseconds, so a generator can issue millions of IDs per second per core; a call to a central ticket server costs a network round trip of hundreds of microseconds. NTP daemons normally slew the clock gradually (chrony's default maximum slew is one twelfth, 83,333 ppm) and step it only when configured to, typically with makestep at boot (chrony docs); a leap second, a manual date change, VM migrations or misconfigured hosts can jump backwards, which is why the generator must check. In a B-tree index, an increasing key always lands on the rightmost leaf page, so inserts touch one hot page that stays in memory and pages fill completely. A random UUID lands on a random leaf among millions: each insert may read a page from disk, pages split and end up roughly half to three quarters full, and the working set becomes the entire index. InnoDB stores rows in the primary key's clustered index and copies the primary key into every secondary index, so a 16-byte random key inflates and fragments all of them.
Why it's built this way
- A central counter costs a network round trip per ID and a single point of failure, so generate locally.
- Local generators must not collide, so each gets a unique worker ID (or enough randomness that collisions are negligible).
- Databases index sorted keys efficiently, so put the timestamp in the high bits to make keys increase over time.
- Clocks can go backwards, so the generator remembers the last timestamp and refuses to reuse it.
- 64-bit integers are cheap everywhere, so pack time, worker and sequence into one word instead of using 128 bits.
Diagram
flowchart TD
S["NextID(now)"] --> C{"now vs last_ms"}
C -->|"now before last_ms"| E["Error or wait: clock moved backwards"]
C -->|"same millisecond"| I["sequence += 1"]
C -->|"now after last_ms"| Z["sequence = 0, last_ms = now"]
I --> O{"sequence over 4095?"}
O -->|"yes"| W["Wait for next ms (or error)"]
O -->|"no"| B["Pack: 41-bit time, 10-bit worker, 12-bit sequence"]
Z --> BWorked example
A messaging platform needs IDs for about 400,000 messages per second at peak across 160 API servers, each running 4 generator processes: 640 generators, under the 1,024 that 10 bits allow. Each generator averages about 625 IDs per second, under one per millisecond, so the 4,096-per-millisecond sequence has a large margin even for a 50-times burst on one process.
Generators lease worker IDs from etcd with a 10-second TTL renewed every 3 seconds. A process that cannot renew stops issuing IDs before its lease could be handed to someone else. The custom epoch is 2024-01-01, so 41 bits last until roughly 2093.
Messages are stored in a table clustered by (conversation_id, message_id); because message IDs increase over time, new messages append to the end of each conversation's range and "latest 50 messages" is a short range scan. The web client receives IDs as strings, because JavaScript numbers are doubles that represent integers exactly only up to 2^53 − 1 and would silently round 64-bit IDs (MDN).
In the wild
- Twitter, 2010: Snowflake forces string IDs. Twitter announced that tweet IDs would become Snowflake IDs, 64-bit unsigned integers. It added
id_strto every JSON response from 22 October, switched Snowflake on with IDs capped at 41 bits on 4 November, and warned that IDs would pass 53 bits around 26 November, after which JavaScript clients reading the numericidwould get rounded values (TechCrunch). X's docs still tell developers to always useid_str(X docs). - Basecamp, 2018: the 32-bit ceiling. Basecamp 3 went read-only for almost five hours (7:21am to 12:11pm CST) when the busy events table hit the signed-integer limit of 2,147,483,647. Staff had been widening the table but misjudged how fast it was filling. The fix was migrating to a 64-bit column (Basecamp postmortem).
- Shopify, 2022: sortable keys halve insert time. Shopify's payments team switched idempotency keys from UUIDv4 to ULIDs (48-bit timestamp plus 80 random bits) and saw INSERT duration drop by 50 percent in one high-throughput system, because time-ordered keys suit the B-tree (Shopify Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Single DB sequence | Dense, strictly increasing, trivial | Bottleneck, single point of failure, leaks volume | One database, modest scale |
| Block allocation (hi-lo) | Rare coordination, 64-bit, mostly ordered | Gaps, ordering only within a block | Sharded services needing numeric IDs |
| UUIDv4 | No coordination at all, opaque | 128-bit, random index inserts, unordered | Low write rates, external opaque IDs |
| UUIDv7 / ULID | No coordination, time-ordered | 128-bit, exposes creation time | New systems wanting sortable UUIDs |
| Snowflake | 64-bit, time-ordered, very fast | Worker ID assignment, clock sensitivity | High-throughput event or message IDs |
Pitfalls in production
- Duplicate worker IDs. Two pods configured with the same ID issue identical IDs. Assign IDs from a lease, not a config file.
- Ignoring clock rollback. After an NTP step back, a naive generator reissues earlier timestamps. Track
last_msand wait or fail. - 64-bit IDs in JavaScript. Values above 2^53 lose precision in JSON parsing. Serialise IDs as strings in public APIs.
- Random UUID primary keys at scale. Insert throughput falls as the index outgrows memory. Use time-ordered IDs for clustered keys.
- Sequential public IDs. Competitors read your order volume from consecutive IDs, and attackers enumerate resources. Expose an opaque or scrambled ID externally.
Leadership lens
- Write down required properties (size, ordering, opacity, scope) before choosing; most disagreements are really about these.
- Standardise one ID library and format across services so IDs are parseable and debuggable everywhere.
- Decide what may be inferred from an ID (creation time, shard) and whether that is acceptable for public exposure.
- Have a migration plan; changing ID formats later touches every table, cache key and client.
Check yourself
Why do Snowflake IDs from different workers only roughly sort by time?
Show answer
Each worker uses its own clock, so skew between machines can order two IDs differently from their real creation order by up to the skew.
A generator sees
now5 ms earlier thanlast_ms. What must it do?Show answer
Wait until the clock passes
last_msor return an error; issuing IDs with the earlier timestamp could duplicate IDs already issued.Why do random UUID primary keys hurt insert performance in a large B-tree?
Show answer
Each insert lands on a random leaf page, so the whole index must stay in memory to avoid disk reads, and random page splits leave pages partly empty.
How many IDs can one Snowflake worker issue per second at most?
Show answer
4,096 per millisecond, so about 4.1 million per second.
Sources and further study
Primary
- Snowflake README, 2010 branch (Twitter): the original requirements (uncoordinated, k-sorted, under 64 bits) and bit layout.
- ULID specification: layout, Crockford encoding and the monotonic mode with its overflow error.
Systems docs
- PostgreSQL: UUID functions:
uuidv7()with sub-millisecond ordering anduuidv4(), both built in since version 18. - Discord API reference: Snowflakes: a production Snowflake variant with worker and process fields, served as strings.
- MySQL: auto_increment_increment and auto_increment_offset: the step-and-offset scheme and its server-wide scope.
Books
- Designing Data-Intensive Applications (2nd edition), Kleppmann and Riccomini, chapter 10 "Consistency and Consensus", section "ID Generators and Logical Clocks": why timestamp IDs do not give a causal order.
- System Design Interview, Volume 1, Alex Xu, chapter 7 "Design A Unique ID Generator In Distributed Systems": multi-master, UUID, ticket server and Snowflake compared at interview depth.
- Database Internals, Alex Petrov, chapter 4 "Implementing B-Trees", section "Right-Only Appends": the fast path that time-ordered keys hit.
Other curricula
- donnemartin/system-design-primer: Pastebin: frames short IDs as a hash truncated to 7 Base62 characters with a collision check, not a counter.
Watch
- Snowflake ID Generation by Twitter (Gaurav Sen): the Snowflake bit layout and why it suits distributed systems.
- System Design - Part 12: Design a Unique ID Generator, 4 Methods (Nikhil Lohia): a comparison of the main ID-generation approaches.
Free resources
- RFC 9562: Universally Unique IDentifiers (IETF): the current UUID standard, including the UUIDv7 layout and monotonicity guidance.
- Ticket servers: distributed unique primary keys on the cheap (Flickr Code): the central-sequence approach done pragmatically.
- Snowflake ID (Wikipedia): the format, its variants and where it is used.
- UUIDs are popular, but bad for performance (Percona): measurements of random-key effects on InnoDB indexes.