Every system needs IDs, and the first instinct is the database's auto-increment column. It works until you have more than one database, or more than one data centre, or a million writes a second. "Design a unique ID generator" asks what you do then. The answer most interviewers expect fits in a single 64-bit integer, and the interesting part is not the layout but the two things it depends on: a clock you can trust and a worker number nobody else is using.
Many services (orders, messages, payments, events) need primary keys generated at high volume across several data centres. Design the ID scheme and the service or library that produces it.
In scope
- The ID format and why
- How generators avoid collisions without talking to each other
- What happens when clocks misbehave
A distributed ID generator hands out unique, 64-bit, roughly time-ordered IDs at an assumed 1M a second with no network call per ID; a random UUID would cost 8 extra bytes per row per index (80 GB per index at 10 billion rows) and scatter inserts. The Snowflake-style layout spends 1 sign bit, 41 bits of milliseconds since a custom epoch (about 69 years), 10 bits of worker ID (1,024 generators) and 12 bits of sequence (4,096 per millisecond), and the 4,097th request in a millisecond waits for the next one. A library in each instance takes its worker ID from an etcd lease and its time from NTP; duplicates come only from a shared worker ID or a clock stepping backwards, and time-ordered IDs make range partitions hot and must reach JavaScript as strings.
Phase 1: Requirements (3 minutes)#
"Do IDs have to be strictly sequential, or just roughly ordered by time? Must they fit in 64 bits? Is it fine if an ID reveals when it was created?"
"Roughly time-ordered is enough: newer IDs should usually be larger, so recent rows sort and index well. 64 bits, because the columns are BIGINT. Revealing creation time is acceptable for internal IDs."
Functional requirements#
next()returns a new ID;next(n)returns a batch- IDs are unique across all generators, forever
- IDs are numeric, 64-bit, and sort roughly by creation time
Non-functional requirements#
- Throughput: about 1M IDs a second across the fleet at peak, with bursts on single hosts
- Latency: generating an ID should cost microseconds, with no network call on the hot path
- Availability: ID generation must not depend on a single central service being up
- Ordering: within one generator, strictly increasing; across generators, ordered to within clock skew
Phase 2: Estimation (3 minutes)#
Before the layout: why not a UUID?
UUIDs need no coordination at all. What do you give up by using a random 128-bit UUID as the primary key of a table with 10 billion rows?
One strong answer
- Size: 16 bytes instead of 8. Every index that includes the primary key grows by 8 bytes per row: 10B × 8 B = 80 GB per index.
- Insert locality: random keys land all over a B-tree, so inserts touch random pages, split pages and use the cache poorly. Time-ordered keys append near the right edge of the index.
- No order: you can't sort by ID to get the newest rows.
Time-ordered 128-bit UUIDv7 (standardised in RFC 9562) fixes the locality and order problems, but not the size, and the brief asked for 64 bits.
- 1 bit sign, always 0, so the value stays positive in a signed BIGINT
- 41 bits of milliseconds since a custom epoch: 2^41 ms ≈ 2.2 trillion ms ≈ 69 years
- 10 bits of worker ID: 1,024 generators
- 12 bits of sequence per millisecond: 4,096 IDs per ms = ~4.1M IDs/s per generator
- Fleet need: 1M IDs/s. In theory one generator could serve it; in practice you run one per service instance (dozens to hundreds), so 1,024 worker IDs is the real constraint to watch
"This is the layout Twitter's Snowflake popularised when it was open-sourced in 2010, and many systems use variations of it. I'd keep the shape and choose the bit split for our own fleet."
Phase 3: API (2 minutes)#
Two ways to expose it, and the embedded library is the default.
# 1 · embedded library: no network hop
ids = SnowflakeGenerator(worker_id=lease.worker_id)
order_id = ids.next() # 64-bit int, e.g. 240044212224069649# 2 · a small ID service, for languages or jobs that can't embed the library
POST /v1/ids?count=100
→ 200 { "ids": ["240044212224069649", "240044212224069650", ...] }IDs go over JSON as strings. JavaScript numbers are exact only up to 2^53 − 1, and a Snowflake ID passes that within weeks of its epoch, so a browser parsing it as a number would silently round it.
Phase 4: High-level design (4 minutes)#
%% title: Snowflake-style IDs, generated inside each service
flowchart TD
OS["Order service instance<br/>ID library · worker 17"]:::core
CS["Chat service instance<br/>ID library · worker 18"]:::core
E@{ icon: "tech:etcd", label: "etcd<br/>worker-ID leases" }
T@{ icon: "sd:clock", label: "NTP time sources" }
DB@{ icon: "sd:database", label: "Databases<br/>B-tree index on id" }
API@{ icon: "sd:api", label: "ID service<br/>(batch endpoint)" }
B@{ icon: "sd:browser", label: "Web clients<br/>IDs as strings" }
OS -->|"lease worker 17, renew every 10 s"| E
CS --> E
API --> E
T -.->|"clock sync"| OS
T -.-> CS
OS -->|"insert id"| DB
CS --> DB
OS -->|"JSON: id as string"| B
class E,DB store
class T,API service
class B client- The library in every service instance builds IDs from its clock, its worker ID and a per-millisecond counter. No network call per ID.
- etcd (or ZooKeeper) hands out worker IDs as leases. An instance must hold a live lease to generate; that is what keeps two instances from using the same worker number.
- NTP keeps clocks close so IDs from different instances are ordered to within a few milliseconds.
- The ID service is the same library behind an endpoint, for callers that can't embed it. Callers fetch IDs in batches so the extra hop is rare.
DB auto-increment
- Simple, strictly ordered
- One primary is a bottleneck and a single point of failure
- Multiple primaries with offsets (server k issues k, k + N, ...) lose time order and are hard to grow
Central range allocator
- A counter hands each server a block of IDs
- Compact 64-bit IDs, no per-ID call
- Blocks break time order across servers, and the allocator must be highly available
UUIDv4 / UUIDv7
- No coordination at all
- 128 bits; v4 is random (poor index locality), v7 is time-ordered
- A fine choice when 64 bits is not a requirement
Phase 5: Deep dives (5 minutes)#
Deep dive 1: Building an ID, and the 4,097th request in a millisecond#
import time
EPOCH_MS = 1735689600000 # custom epoch: 2025-01-01T00:00:00Z
WORKER_BITS, SEQ_BITS = 10, 12
MAX_SEQ = (1 << SEQ_BITS) - 1 # 4,095
class SnowflakeGenerator:
def __init__(self, worker_id: int):
assert 0 <= worker_id < (1 << WORKER_BITS)
self.worker_id, self.last_ms, self.seq = worker_id, -1, 0
def next(self) -> int:
now = int(time.time() * 1000)
if now < self.last_ms: # clock went backwards: deep dive 2
now = self._wait_until(self.last_ms)
if now == self.last_ms:
self.seq = (self.seq + 1) & MAX_SEQ
if self.seq == 0: # 4,096 used this ms: wait for the next ms
now = self._wait_until(self.last_ms + 1)
else:
self.seq = 0
self.last_ms = now
return ((now - EPOCH_MS) << (WORKER_BITS + SEQ_BITS)) | (self.worker_id << SEQ_BITS) | self.seq
def _wait_until(self, target_ms: int) -> int:
while (now := int(time.time() * 1000)) < target_ms:
time.sleep(0.0001)
return now- Timestamp first makes IDs sort by time, so new rows append at the end of the index.
- The sequence separates IDs generated in the same millisecond by the same worker. The 4,097th request in a millisecond waits for the next millisecond (at most 1 ms), so it is a throughput ceiling of ~4.1M a second per generator, never a duplicate.
- A custom epoch (2025-01-01 here, not 1970) spends the 69 years from when the system starts. (The code is a sketch: a real generator also guards
next()with a lock if threads share it.) - Order across workers is only as good as clock sync: two IDs made 1 ms apart on different hosts can come out in either order. That is "roughly time-ordered", which is what the interviewer agreed to.
Deep dive 2: The two ways to get a duplicate#
"Your layout can't collide on paper. How does it produce a duplicate in production?"
"Two ways: two generators with the same worker ID, or one generator whose clock goes backwards and reuses a millisecond it already used."
- Lease on start: the instance claims a free worker ID in etcd with a lease (say 30 s TTL) and renews it every 10 s.
- Stop if the lease is lost: if a renewal fails and the lease might have expired, the library stops issuing IDs until it holds a lease again. A paused VM that wakes up after its lease expired must not continue with its old number.
- Fence the gap: after re-acquiring, wait longer than the maximum clock skew before issuing, so the new holder's IDs can't overlap the old holder's last milliseconds.
- Don't put worker IDs in config files: two copies of the same deployment template are the classic way to get two worker 17s.
- Why it happens: NTP can step a clock back, a VM can migrate to a host with a different clock, an operator can fix the time by hand.
- Small step (a few ms): wait until the clock passes the last timestamp used, as the code does. Generation pauses briefly; nothing collides.
- Large step: refuse to generate and alert, rather than waiting many seconds silently or issuing duplicates.
- Prevention: run NTP in slewing mode (adjust the rate, don't jump), and have the generator remember the last timestamp across restarts or wait out the maximum skew after starting.
Layout and duplicates
Q1/2 A worker is asked for its 4,097th ID within the same millisecond. What does the generator do?
Answer & explanation
Answer: B
12 sequence bits give 4,096 IDs per millisecond. The next one waits at most 1 ms, which caps throughput at about 4.1M IDs a second per generator.
Q2/2 Which situation can produce duplicate IDs with a correct Snowflake layout?
Answer & explanation
Answer: C
Different worker IDs make same-millisecond IDs differ, and the sequence overflow waits. A shared worker ID (or a clock going backwards) is what collides.
Phase 6: Trade-offs and failure modes (2 minutes)#
IDs leak information
Anyone can read the creation time out of an ID, and two IDs a day apart reveal roughly how many were made in between. If that matters (a competitor estimating your order volume), expose a separate random public ID and keep the Snowflake ID internal.
Monotonic keys make hot partitions
Time-ordered IDs all land at the end of the key space. If a table is range-partitioned by ID, every insert goes to the last partition. Hash-partition by ID, or partition by something else.
Running out of bits
41 bits of milliseconds last about 69 years from the epoch. 10 worker bits allow 1,024 generators; a large fleet may need a split like 5 bits of data centre and 5 of worker, or fewer sequence bits.
etcd is down
Instances that already hold a lease keep generating while they can renew; new instances can't start generating. Keep leases long enough to ride out a short outage.
JavaScript clients
Return IDs as strings in JSON. Precision loss above 2^53 doesn't error; it silently changes the ID.
Phase 7: Go deeper in the course#
- Leases, fencing tokens and the zombie that wakes up: Week 5 · Day 5: Leader Election and Coordination
- Why time-ordered keys create hot partitions: Week 1 · Day 1: Partitioning Deep-Dive and Week 1 · Day 4: Hot Keys and Skew
- Clock skew and ordering events by time: Week 3 · Day 5: Audit Log System
- IDs as idempotency keys: Week 2 · Day 2: Idempotency in Practice
- Range allocation in practice (short codes from leased blocks): Week 1 Capstone: URL Shortener at Scale
- Powers of two and capacity math: Week 0 · Part 3: Back-of-the-Envelope Estimation
Final check#
Final check · Unique IDs
Q1/3 With 41 bits of milliseconds, how long does a Snowflake-style scheme last from its epoch?
Answer & explanation
Answer: C
2^41 ms is about 2.2 trillion milliseconds, roughly 69 years. A custom epoch starts that clock when the system starts, not in 1970.
Q2/3 Why send 64-bit IDs to browsers as JSON strings?
Answer & explanation
Answer: B
Snowflake IDs exceed 2^53 soon after the epoch. A browser parsing one as a number silently rounds it to a different ID.
Q3/3 An orders table is range-partitioned by its Snowflake ID. What goes wrong?
Answer & explanation
Answer: D
Time-ordered keys always grow, so new rows pile onto the newest range. Hash-partition by ID instead.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.