Unique ID Generation - Complete Deep Dive
Prerequisites: Database Sharding, Terminology Used in: Unique ID Generator, URL Shortener, Chat System, Twitter/X β every design that creates records at scale
Why this is a real interview topic
The moment you shard, AUTO_INCREMENT stops working β two shards will happily hand out ID 1000. So βhow do you generate IDs?β is really βhave you thought about what breaks when you shard?β
There are five requirements, and they conflict:
| Requirement | Why you want it |
|---|---|
| Unique | Non-negotiable |
| Sortable by time | Cursor pagination, βnewest firstβ without a secondary index, index locality |
| Compact | Every index, every foreign key, every log line pays for the bytes |
| No coordination | A central allocator is a bottleneck and a single point of failure |
| Not guessable | Sequential public IDs leak volume (βtheyβve only had 4,300 ordersβ) and enable enumeration attacks |
Time-sortable and not-guessable are in direct tension. Naming that tension is most of a good answer.
The options
| Approach | Bits | Sortable | Coordination | Notes |
|---|---|---|---|---|
| Auto-increment | 64 | β Perfect | Single DB | Breaks on shards, SPOF, leaks volume |
| UUID v4 (random) | 128 | β None | None | Trivial to generate, terrible index locality |
| UUID v1 (time+MAC) | 128 | β οΈ Poor byte order | None | Leaks MAC address; timestamp bytes are in the wrong order to sort |
| UUID v7 | 128 | β Yes | None | Unix ms prefix + random. The modern default. |
| ULID | 128 (26 chars) | β Yes | None | Timestamp + random, base32, lexicographically sortable as a string |
| Snowflake | 64 | β Yes | Node ID assignment only | Twitterβs design. Compact + sortable. The interview favorite. |
| Ticket server | 64 | β Yes | Central service | Flickrβs approach; batch ranges to reduce the bottleneck |
| DB sequence per shard | 64 | β οΈ Per-shard | Per-shard only | Offset+increment so shards never collide |
| Redis INCR | 64 | β Yes | Redis | Fast, but Redis becomes critical-path and persistence matters |
Snowflake β know this one cold
64 bits, split into meaningful fields:
1 41 bits 10 bits 12 bits
βββ¬βββββββββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ
β0β timestamp β machine id β sequence β
βββ΄βββββββββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ
β β β β
β β β ββ 4,096 IDs per ms per machine
β β ββ 1,024 machines
β ββ ms since a custom epoch β 2^41 ms β 69 years
ββ sign bit, always 0 so the ID is a positive signed 64-bit int
Capacity: 4,096 Γ 1,024 = 4.2 million IDs per millisecond = 4.2 billion/second. More than enough for anything.
Why the fields are in that order: timestamp first means the ID sorts by time when compared as an integer. Put the machine ID first and youβd get IDs clustered by machine instead β useless for sorting and bad for index locality.
public class SnowflakeGenerator {
private static final long EPOCH = 1735689600000L; // 2025-01-01, custom epoch
private static final long MACHINE_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; // 4095
private final long machineId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeGenerator(long machineId) {
if (machineId < 0 || machineId >= (1L << MACHINE_BITS))
throw new IllegalArgumentException("machineId out of range");
this.machineId = machineId;
}
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastTimestamp) {
// Clock moved backwards (NTP correction). Refusing is safer than
// risking a duplicate β see the clock-skew section below.
throw new IllegalStateException(
"Clock moved backwards by " + (lastTimestamp - now) + "ms");
}
if (now == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) now = waitNextMillis(lastTimestamp); // 4096 used up
} else {
sequence = 0L;
}
lastTimestamp = now;
return ((now - EPOCH) << (MACHINE_BITS + SEQUENCE_BITS))
| (machineId << SEQUENCE_BITS)
| sequence;
}
private long waitNextMillis(long last) {
long now = System.currentTimeMillis();
while (now <= last) now = System.currentTimeMillis();
return now;
}
}
The three things interviewers probe about Snowflake:
1. How does a machine get its ID?
- ZooKeeper/etcd sequential node β robust, adds a coordination dependency at startup only
- Kubernetes StatefulSet ordinal (
pod-0β 0) β free and stable, my usual answer - Derived from the private IPβs last octets β no coordination, but collides if subnets are reused
- Config/env var β simplest, and someone will eventually deploy two pods with the same value
β οΈ Duplicate machine IDs are the silent killer. Two machines with the same ID producing IDs in the same millisecond generate identical IDs. Whatever scheme you use, it must make duplicates impossible, not just unlikely.
2. What about clock skew / NTP going backwards? Three defensible answers:
- Refuse to generate while
now < lastTimestamp(what the code above does) β correctness over availability. Usually right, since the window is milliseconds. - Wait it out if the drift is small (a few ms), fail if large.
- Borrow from the sequence β some implementations bump a logical counter to stay monotonic without the wall clock.
Also: run NTP with slew mode rather than step adjustments so the clock is nudged rather than jumped, and use a monotonic clock where you can.
3. What if you exhaust 4,096 IDs in one millisecond? Spin until the next millisecond (as above). If you routinely hit that, you need more machine IDs, not more sequence bits β and you can rebalance the bit split (e.g. 41/12/10 for fewer machines and more per-ms throughput) since the split is yours to choose.
Say this: βSnowflake, with machine IDs from the StatefulSet ordinal so duplicates are structurally impossible. 41 bits of ms timestamp gives 69 years from a custom epoch, and Iβd refuse to issue IDs if the clock steps backwards rather than risk a duplicate. Itβs 64 bits, so itβs half the index size of a UUID and it sorts by time.β
UUID v4 vs UUID v7 β and why random IDs hurt
| Β | UUID v4 | UUID v7 |
|---|---|---|
| Structure | 122 random bits | 48-bit Unix ms + 74 random bits |
| Sortable | No | Yes |
| Index behaviour | Random inserts across the whole B-tree | Sequential appends to the right edge |
| Guessable | No | Timestamp is visible, rest is random |
| Standard | RFC 4122 | RFC 9562 (2024) |
The random-insert problem, concretely:
UUID v4 inserts land uniformly across the B-tree:
β every insert dirties a different page
β the working set is the ENTIRE index, not just the hot end
β once the index exceeds RAM, each insert becomes a random disk read
β page splits everywhere β fragmentation β bloat
β MySQL InnoDB is worst hit: the PK is clustered, so random PKs
physically scatter your row data too
UUID v7 / Snowflake inserts land at the right edge:
β the same few pages stay hot in cache
β sequential writes, minimal splits, dense pages
This is a measurable, order-of-magnitude difference on write-heavy tables β not a micro-optimization. Itβs the single best reason to prefer v7 over v4 in 2026.
β οΈ Never store a UUID as a 36-char string. char(36) is 36 bytes plus overhead vs 16 bytes binary β and itβs in every index and every foreign key. Use uuid in Postgres, BINARY(16) in MySQL.
ULID vs UUID v7: essentially the same idea (48-bit ms + randomness). ULIDβs advantage is its canonical form: 26 characters of Crockford base32, no hyphens, case-insensitive, lexicographically sortable as a string β which matters if IDs land in S3 keys, log filenames, or a KV storeβs sort key. UUID v7 wins on standardization and native DB support. Either is a good answer; UUID v7 is the safer default now.
Ticket servers and range allocation
A central service hands out IDs. The naive version (one round trip per ID) is a bottleneck, so hand out ranges:
App A β "give me 1000 IDs" β ticket server returns [1000, 1999]
App A now serves 1,000 IDs locally with zero network calls
App B β gets [2000, 2999]
The ticket server itself is just a row: UPDATE counters SET value = value + 1000 RETURNING value.
| Β | Pro | Con |
|---|---|---|
| Range allocation | Amortizes the round trip 1000:1; IDs are compact and dense | Gaps when an app restarts mid-range; roughly-ordered, not strictly ordered across apps |
Flickrβs classic HA trick: two MySQL servers, one issuing odd IDs and one even (auto_increment_offset 1 and 2, auto_increment_increment 2). Either can die without stopping ID generation. Elegant, and a great thing to be able to name.
β οΈ Gaps are fine; assuming density is not. If your business logic infers βwe have N ordersβ from the max ID, range allocation and Snowflake both break that assumption. Count rows.
Public-facing IDs β the security half
Sequential public IDs are two vulnerabilities at once:
- Enumeration / IDOR β
/orders/1001invites/orders/1002. (Authorization is the real fix; unguessable IDs are defense in depth.) - Business intelligence leak β sign up twice a week apart, subtract the IDs, and you know a competitorβs growth rate. This has genuinely leaked real companiesβ numbers.
The standard answer: separate internal and external IDs.
internal id: Snowflake bigint β PK, foreign keys, joins, sorting
external id: random 22-char slug β URLs, API responses
(unique index on it)
You get index locality and unguessability, at the cost of one extra indexed column.
Base62 encoding (for short URLs β this comes up constantly):
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode(n: int) -> str:
if n == 0: return ALPHABET[0]
out = []
while n:
n, rem = divmod(n, 62)
out.append(ALPHABET[rem])
return ''.join(reversed(out))
The capacity math to have ready:
62^6 β 56.8 billion β 6 chars is plenty for most URL shorteners
62^7 β 3.5 trillion
β οΈ Base62 of a sequential counter is still sequential β aZ, b0, b1. Encoding is compression, not obfuscation. If you need unguessable short codes, generate random ones and check for collisions (or use a keyed permutation of the counter, e.g. Feistel/hashids, which is bijective so it needs no lookup table).
Sharding interaction
Two patterns worth knowing:
Embed the shard in the ID. Reserve bits (or reuse the machine-ID field) for a shard number, so any service can route a request from the ID alone with no lookup table.
Instagram's approach: 41 bits time | 13 bits shard id | 10 bits per-shard sequence
The trade-off: the shard is now baked into the ID forever, so resharding means IDs whose embedded shard is a lie. You handle it with a mapping layer for moved ranges β which is exactly the kind of trade-off worth stating out loud.
Offset sequences per shard. Shard 0 issues 1, 5, 9β¦; shard 1 issues 2, 6, 10β¦. Simple, no coordination, but adding shards breaks the scheme.
Interview application
URL shortener: βSnowflake for the internal ID, then base62-encode a random 7-character code for the public short URL with a uniqueness check on insert β encoding the counter directly would make every URL guessable, which is a scraping and privacy problem. At 100M links, 62^7 keeps collision probability negligible, and a single retry on the unique-index violation handles the rest.β
Chat system: βSnowflake message IDs, because time-sortability gives me cursor pagination and βmessages after Xβ without a separate timestamp index. Per-conversation ordering matters more than global ordering, so I only need monotonicity within a conversation β and since a conversation is pinned to a partition, the per-node sequence handles it.β
E-commerce orders: βInternal Snowflake bigint for the PK and all foreign keys. Public order numbers are a separate random alphanumeric, because sequential order numbers let a competitor measure our order volume by placing two orders a week apart.β
Common Interview Questions
Q: βWhy not just use auto-increment?β A: βIt works until you shard β two shards independently issue ID 1000. Itβs also a single point of failure for writes, and the IDs leak your volume publicly. Fine for a single-DB system, and itβs the right answer if youβre not sharding.β
Q: βUUID or Snowflake?β A: βSnowflake if I control the servers: 64 bits instead of 128, time-sortable, and index-friendly. UUID v7 if IDs must be generated client-side or offline with zero coordination β itβs also sortable, just twice the bytes. Iβd avoid UUID v4 as a primary key because random inserts scatter B-tree writes across the whole index, which collapses write throughput once the index exceeds RAM.β
Q: βHow do you assign machine IDs?β A: βKubernetes StatefulSet ordinals β stable, unique by construction, no extra dependency. Otherwise a ZooKeeper/etcd sequential node at startup. The requirement is that duplicates are impossible, because two machines sharing an ID will emit identical IDs in the same millisecond.β
Q: βWhat if the clock goes backwards?β A: βRefuse to generate until the clock catches up, rather than risk duplicates β the window is usually milliseconds. Iβd also configure NTP to slew rather than step so the clock is never jumped, and monitor drift.β
Q: βAre Snowflake IDs strictly ordered globally?β A: βNo β only monotonic per generator. Two IDs from different machines in the same millisecond have an arbitrary relative order. Theyβre roughly time-ordered, which is enough for pagination and sorting but not for anything requiring a total order. If I need a total order I need consensus, and Iβd question the requirement first.β
Q: βHow do you avoid collisions in a random short code?β A: βUnique index on the code plus retry on violation. With 62^7 β 3.5 trillion codes and 100M in use, the collision rate is tiny, so retries are rare. The alternative β pre-generating a pool of unused codes in a table and handing them out β trades storage for guaranteed O(1) allocation with no retry loop, which is nicer under a flash of traffic.β
Q: βClient-generated or server-generated IDs?β A: βClient-generated (UUID v7/ULID) makes retries naturally idempotent β the client can safely resend because the ID doubles as an idempotency key β and it lets an offline-first app create records with no network. The cost is 128 bits, plus you must never trust the clientβs ID for anything security-relevant. Server-generated is more compact and controlled. For a mobile-first app with offline support, client-generated wins.β
Decision Framework
| Situation | Choose |
|---|---|
| Single database, not sharding soon | Auto-increment bigint |
| Sharded, you control the servers | Snowflake |
| Need offline/client-side generation | UUID v7 or ULID |
| IDs appear in S3 keys / log filenames / sort keys | ULID (string-sortable) |
| Public-facing URLs or API IDs | Separate random external ID |
| Short URL codes | Random base62, unique index + retry |
| Must route to a shard from the ID alone | Embed the shard in the ID (accept resharding cost) |
| Legacy-compatible dense integers | Ticket server with range allocation |
| Never | UUID v4 as a clustered primary key on a write-heavy table |
| β Back to Fundamentals | β Deployment & Reliability | Next: Observability β |