- Every post, comment, like, story and message needs a unique ID. In a sharded system there is no single database whose auto-increment counter can hand them out safely and fast.
- Good IDs are 64-bit (fit in a
BIGINT), roughly time-sortable (newer = bigger, great for B-tree indexes and feed pagination), generated at high throughput with no single point of failure and no coordination per ID. - Options: DB auto-increment, multi-master step offsets, UUID v4 (random — bad index locality), UUID v7 / ULID (time-ordered, 128-bit), ticket servers (Flickr), and Twitter Snowflake: 41-bit timestamp + 10-bit machine ID + 12-bit sequence.
- Instagram's publicly described scheme: 41 bits of milliseconds since a custom epoch + 13 bits of logical shard ID + 10 bits of per-shard sequence, generated inside PostgreSQL by a PL/pgSQL function. Because the shard ID is inside the ID, any service can route a request for that object to the right shard with a bit-shift — no lookup.
- The main hazard of time-based IDs is the clock: skew between machines only loosens ordering, but a clock going backwards can create duplicates unless the generator waits or refuses.
1. The problem: why is generating an ID hard?
On a single-server app you write id SERIAL PRIMARY KEY and forget about it: PostgreSQL hands out 1, 2, 3… Instagram is different. Posts, comments and likes are spread across thousands of database shards (see Sharding), and the write volume is enormous — assume, for practice, ~100M posts, ~1B likes and ~100M comments per day, peaking at well over 100,000 new objects per second.
- If each shard uses its own auto-increment counter, shard 7 and shard 912 will both create a post with ID 1,000,000. IDs collide the moment you look at data across shards (in caches, in Kafka events, in the feed, in URLs).
- If one central database hands out all IDs, every write in the company depends on it: a single point of failure and a throughput ceiling, plus a cross-data-center round trip for each ID.
- If you use random 128-bit UUIDs, you get uniqueness but lose ordering and bloat every index (more on this below).
We want IDs that are unique without asking anyone, small, and that sort by creation time — so "show the newest posts" is just "sort by ID descending".
2. Requirements for Instagram's IDs
| Requirement | Why it matters for Instagram |
|---|---|
| Globally unique | IDs are used as cache keys, Kafka message keys, URL slugs and foreign keys across services. A duplicate means one user's post overwriting another's. |
| 64 bits | Fits in a native BIGINT/long: 8 bytes per ID instead of 16 for a UUID — halving index size for billions of rows, and comparisons are a single CPU instruction. |
| Roughly time-sortable (k-sorted) | Sort by ID ≈ sort by creation time without an extra created_at index; feed pagination uses "give me posts with ID < cursor"; new rows append at the right edge of B-tree indexes. |
| High throughput, low latency | Hundreds of thousands of IDs/s overall, generated in microseconds, on the write path. |
| No single point of failure | ID generation must not be the component that takes Instagram down. |
| No per-ID coordination | No network round trip or lock shared across machines for each new ID. |
| Bonus: routable | If the ID tells you which shard holds the object, you can fetch it without a directory lookup. |
"Roughly" time-sortable is deliberate: IDs generated on different machines in the same few milliseconds may be slightly out of order (because their clocks differ slightly). That's fine for a feed; it is not a substitute for a strict global order.
3. The options, one by one
3.1 Database auto-increment
One database with an auto-increment column (SERIAL, AUTO_INCREMENT, IDENTITY) generates every ID.
- Pros: simple, compact (64-bit), perfectly ordered, dense.
- Cons: single point of failure and write bottleneck; doesn't work across shards; IDs are guessable and leak business volume (a competitor can create two posts a day apart and subtract the IDs to learn your daily post count — the classic "German tank problem").
3.2 Multi-master with step offsets
Run N ID databases; server i starts at i and increments by N. With N = 2: server A produces 1, 3, 5…, server B produces 2, 4, 6… MySQL supports this directly with auto_increment_increment = N and auto_increment_offset = i.
- Pros: no SPOF; doubles (N×) throughput; still 64-bit.
- Cons: adding a server changes N and requires re-planning offsets; IDs aren't time-ordered across servers (A may be at 1,000,001 while B is at 5,000,002); still a network round trip per ID.
3.3 UUID v4 — and the index locality problem
A UUID is a 128-bit identifier, written as 36 characters like 9f1c2a7e-3b4d-4e8f-a1b2-c3d4e5f60718. Version 4 fills 122 bits with randomness. Any machine can generate one offline; the chance of a collision is negligible (you'd need ~2.7 × 1018 UUIDs for a 50% chance of a single duplicate).
So why not use them everywhere? Two reasons: size (16 bytes binary, 36 as text — every index and every foreign key that references it doubles or more) and, more importantly, randomness destroys index locality.
B-tree primary index (leaf pages ordered by key)
Time-ordered IDs: [p1][p2][p3] ... [p97][p98][p99*] ← every insert lands in the LAST page
(it stays hot in RAM; pages fill 100%)
Random UUID v4: [p1][p2*][p3] ... [p41*][p42] ... [p97][p98*][p99]
↑ inserts land on random pages all over the index
→ each insert may read a cold page from disk
→ full pages split in half (~50-70% fill), index bloats
→ WAL / replication volume grows, cache hit ratio falls
With billions of rows, the index is far larger than RAM. Time-ordered keys keep the "insert frontier" (the right-most pages) in memory; random keys make every insert a potential random disk read and page split. Benchmarks routinely show insert throughput dropping several-fold once a random-UUID index outgrows memory. Random IDs also give no time ordering, so you need an extra created_at index for "newest first" queries. (See Indexing for how B-trees work.)
3.4 UUID v7 and ULID — time-ordered 128-bit IDs
Newer formats keep the "generate anywhere" property but put a timestamp first:
UUID v7 (RFC 9562, 2024) — 128 bits | unix_ts_ms (48) | ver=7 (4) | rand_a (12) | var (2) | rand_b (62) | ULID — 128 bits, encoded as 26 Crockford base32 chars, e.g. 01JAZ3K5T8X2N9QW4VBCDEFGHJ | timestamp ms (48) | randomness (80) |
- Pros: time-sortable (good B-tree locality), no coordination, no machine IDs to assign, standard library support growing (PostgreSQL 18 adds a native
uuidv7()function). ULID's text form is URL-friendly and lexicographically sortable. - Cons: still 128 bits (2× a BIGINT); ordering within the same millisecond on different machines is random; carries no routing information. A great default when 128 bits is acceptable — and a common modern interview answer.
3.5 Ticket servers (Flickr)
Flickr publicly described a "ticket server": a dedicated MySQL database whose only job is handing out IDs from an auto-increment column, using a one-row table trick:
CREATE TABLE tickets64 (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
stub CHAR(1) NOT NULL DEFAULT '',
PRIMARY KEY (id), UNIQUE KEY (stub)
) ENGINE=InnoDB;
REPLACE INTO tickets64 (stub) VALUES ('a'); -- deletes + reinserts the single row, bumping id
SELECT LAST_INSERT_ID(); -- per-connection, so concurrent clients don't clash
For availability they ran two ticket servers, one producing odd and one even numbers (step offsets), with clients round-robining between them.
- Pros: 64-bit, compact, simple, roughly ordered, battle-tested.
- Cons: a network round trip per ID; the ticket servers are a central dependency and a throughput ceiling; odd/even servers drift apart so ordering is loose.
- Common improvement — segment (range) allocation: instead of one ID per request, each app server grabs a block (e.g. 1,000 IDs:
UPDATE seq SET max_id = max_id + 1000) and hands them out locally from memory. One DB round trip per 1,000 IDs; a crash wastes at most the unused part of a block. Meituan's open-source Leaf uses this "segment mode" with double buffering.
3.6 Twitter Snowflake
In 2010 Twitter moved off MySQL auto-increment and published Snowflake, a service that composes a 64-bit ID from three fields, so each generator can work independently:
- 41-bit timestamp — milliseconds since a custom epoch (Twitter used 1288834974657 = 2010-11-04 01:42:54.657 UTC). 241 ms ≈ 69.7 years, so Twitter's IDs last until ~2080. A custom epoch (instead of 1970) avoids wasting 40 years of range.
- 10-bit machine ID — up to 1,024 generators (Twitter split it as 5 bits data center + 5 bits worker). Assigned via configuration or a coordination service like ZooKeeper — coordination only at startup, never per ID.
- 12-bit sequence — a counter that resets every millisecond: up to 4,096 IDs per millisecond per machine (~4.1M/s per machine). If exhausted, the generator waits for the next millisecond.
Snowflake-style IDs are used widely: Discord, Mastodon and many others use variants; Sony's Sonyflake uses 39 bits of 10 ms units, 8 bits of sequence and 16 bits of machine ID (more machines, fewer IDs per machine, longer lifetime).
3.7 Comparison
| Approach | Size | Time-sortable | Coordination per ID | SPOF | Throughput | Notes |
|---|---|---|---|---|---|---|
| DB auto-increment | 64-bit | Yes (strictly) | Yes (DB round trip) | Yes | One DB's write rate | Leaks counts; not shard-friendly |
| Step offsets (N masters) | 64-bit | Only per server | Yes | No | N × one DB | Hard to change N |
| UUID v4 | 128-bit | No | No | No | Unlimited | Poor B-tree locality, big indexes |
| UUID v7 / ULID | 128-bit | Yes (ms) | No | No | Unlimited | Great default if 128 bits OK |
| Ticket server (+segments) | 64-bit | Roughly | Yes (or per block) | Mitigated with 2+ | High with blocks | Central dependency |
| Snowflake | 64-bit | Roughly (ms) | No (machine ID at startup) | No | 4,096/ms per worker | Clock-dependent; needs machine IDs |
| Instagram scheme | 64-bit | Roughly (ms) | No (inside the shard's DB) | No | 1,024/ms per shard | Encodes shard → routing for free |
4. Instagram's ID scheme
In 2012 Instagram's engineering team published a well-known post ("Sharding & IDs at Instagram") describing how they generate IDs for their sharded PostgreSQL. They considered the options above and rejected them: UUIDs were too big and unordered; a Snowflake service was another moving part to run (with ZooKeeper for machine IDs); ticket servers were a central dependency. Their solution: make each logical shard generate its own IDs inside PostgreSQL, embedding the shard number in the ID.
4.1 The bit layout
- 41 bits — time: milliseconds since a custom epoch in 2011 → ~69.7 years of range. (Because PostgreSQL's
BIGINTis signed and this layout uses all 64 bits, the top bit becomes 1 after 240 ms ≈ 34.8 years — around 2045–2046 — at which point IDs would turn negative and sort incorrectly unless the scheme is adjusted. A fun detail to mention in an interview.) - 13 bits — logical shard ID: up to 8,192 logical shards. Instagram described creating thousands of logical shards (implemented as PostgreSQL schemas) mapped onto a much smaller number of physical database servers, so shards can later be moved to new machines without re-sharding data.
- 10 bits — sequence: each table in each shard has a normal PostgreSQL sequence; take
nextval() mod 1024. Up to 1,024 IDs per shard per millisecond.
4.2 Generated inside PostgreSQL
The ID is produced by a PL/pgSQL function used as the column default, so every INSERT gets an ID atomically in the same database that stores the row — no extra service, no extra network hop. A function in the spirit of the published one (each logical shard is a schema, here shard_1341, with its own copy of the function and its own shard constant):
CREATE SEQUENCE shard_1341.post_id_seq;
CREATE OR REPLACE FUNCTION shard_1341.next_id(OUT result BIGINT) AS $$
DECLARE
our_epoch BIGINT := 1293840000000; -- 2011-01-01T00:00:00Z in ms (custom epoch)
shard_id INT := 1341; -- this schema's logical shard number
seq_id BIGINT;
now_millis BIGINT;
BEGIN
SELECT nextval('shard_1341.post_id_seq') % 1024 INTO seq_id;
SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis;
result := (now_millis - our_epoch) << 23; -- 41 bits of time in the high bits
result := result | (shard_id << 10); -- 13 bits of shard
result := result | seq_id; -- 10 bits of sequence
END;
$$ LANGUAGE plpgsql;
CREATE TABLE shard_1341.posts (
id BIGINT NOT NULL DEFAULT shard_1341.next_id() PRIMARY KEY,
user_id BIGINT NOT NULL,
caption TEXT,
media_key TEXT NOT NULL
);
Notes: clock_timestamp() (not now()) is used because now() is frozen at the start of the transaction; PostgreSQL sequences are non-transactional and lock-free enough to handle very high rates; and the PRIMARY KEY constraint is a final safety net — in the unlikely event of a duplicate, the insert fails and can be retried instead of silently corrupting data. (Instagram's post used its own epoch constant and schema names; the structure is the same.)
4.3 Worked example: encoding an ID
User 31341 posts a photo on 2026-10-23 at 09:20:00.000 UTC (14:50 IST). Suppose users map to logical shards by user_id % 2000, so the shard is 31341 mod 2000 = 1341, and this shard's sequence returns 1001.
Step 1 — time since epoch
now = 1,792,747,200,000 ms (2026-10-23T09:20:00Z in Unix ms)
epoch = 1,293,840,000,000 ms (2011-01-01T00:00:00Z)
t = 498,907,200,000 ms (needs 39 of the 41 bits)
Step 2 — shift each field into position
t << 23 = 4,185,136,929,177,600,000
1341 << 10 = 1,373,184
1001 mod 1024 = 1,001
Step 3 — OR them together (no overlapping bits, so OR = add)
id = 4,185,136,929,177,600,000 + 1,373,184 + 1,001
= 4,185,136,929,178,974,185 (hex 0x3A1497DD0014F7E9)
Binary view (64 bits):
00111010000101001001011111011101000000000 | 0010100111101 | 1111101001
└──────────── time (41) ────────────────┘ └ shard 1341 ┘ └ seq 1001 ┘
4.4 Worked example: decoding an ID
id = 4185136929178974185
t = id >> 23 = 498907200000 → + epoch = 1792747200000 ms = 2026-10-23 09:20:00 UTC
shard = (id >> 10) & 0x1FFF = 1341 → lives on logical shard 1341
seq = id & 0x3FF = 1001
Any service — the feed, the like service, a debugging tool — can therefore answer "when was this created?" and "which shard holds it?" from the ID alone.
4.5 Why embedding the shard ID enables routing without lookups
Without it, a request like GET /media/4185136929178974185 would need a directory lookup ("which shard owns post X?") — another distributed table with billions of entries, another hop, another thing to cache and keep consistent. With the shard in the ID, routing is pure arithmetic plus a tiny, static map:
- The logical → physical map has at most 8,192 entries — small enough to cache in every app server and update rarely (when shards move).
- Co-location: Instagram described sharding by user, so a post's ID carries the author's shard. Objects that belong together (a user's posts, likes stored with the post) can be placed on the same shard by reusing the same shard ID, enabling single-shard queries.
- Resharding friendliness: because IDs point at logical shards, moving logical shard 1341 from pg-db-07 to a new server changes one map entry; no IDs change.
5. Clocks: skew, NTP and time going backwards
Time-based IDs are only as good as the clocks that produce them. Server clocks are quartz oscillators that drift (tens of parts per million — up to a few seconds per day if uncorrected), so they're continuously disciplined by NTP (Network Time Protocol) or PTP, which typically keeps servers in a data center within about a millisecond of each other (better with modern setups like chrony + PTP).
5.1 Clock skew between machines: harmless-ish
If machine A's clock runs 3 ms ahead of machine B's, an ID created on B slightly after one on A may get a smaller value. Result: IDs are k-sorted ("roughly sorted, within the skew") rather than strictly ordered. For a feed this is invisible. It only matters if you wrongly rely on IDs for strict ordering — e.g. "process events in ID order to get causality". Use a log/sequence (e.g. Kafka offsets within a partition) for that. See Consistency Models.
5.2 The clock going backwards: dangerous
A clock can jump backwards when NTP steps a badly-off clock, after a VM migration or snapshot restore, from manual changes, or around leap seconds. If a generator issued IDs at t = 1000 ms and the clock jumps back to t = 990, it could re-issue the same (time, machine, sequence) triples → duplicate IDs. Defences:
- Remember the last timestamp used. If
now < last_ms, never generate with the smaller time. - Small regression (a few ms): wait until the clock passes
last_msagain, then continue. Costs a few ms of latency, rarely. - Large regression: refuse (raise an error, mark the node unhealthy, alert) so a load balancer routes ID generation elsewhere. Twitter's original Snowflake did exactly this.
- Alternatively, "borrow from the future": keep using
last_ms(logically) and keep incrementing the sequence until real time catches up. Never goes backwards, but drifts from real time while borrowing. - Prevent big jumps operationally: configure NTP/chrony to slew (gradually speed up/slow down) instead of step after boot; use leap-second smearing (spreading the extra second over many hours, as Google and AWS time services do); monitor clock offset.
Instagram's in-database scheme has a natural extra safety net: the 10-bit sequence comes from a monotonically increasing PostgreSQL sequence, so after a small backwards jump a duplicate would require the sequence to wrap to the same value within the same repeated millisecond, and the primary-key constraint would reject it anyway.
6. Throughput and lifetime math
Snowflake (per worker): 2^12 = 4,096 IDs/ms → 4,096,000 IDs/s per worker
× 1,024 workers (2^10) → ~4.2 billion IDs/s theoretical ceiling
Instagram (per logical shard): 2^10 = 1,024 IDs/ms → 1,024,000 IDs/s per shard
× 8,192 shards (2^13) → ~8.4 billion IDs/s theoretical ceiling
Realistic need (assumption): ~1.2B objects/day ≈ 14K/s avg, ~50K/s peak
Even a single hot shard at 10K inserts/s uses only ~10 of 1,024 sequence values per ms.
Lifetime: 2^41 ms = 2,199,023,255,552 ms ≈ 69.7 years
Twitter epoch 2010-11-04 → exhausted ~2080
41-bit signed caveat (all 64 bits used): top bit flips after 2^40 ms ≈ 34.8 years
The design question is how to split the bits: more machine/shard bits → more generators; more sequence bits → more IDs per generator per ms; more time bits → longer lifetime or finer resolution. Instagram traded Snowflake's 12 sequence bits for 13 shard bits because their bottleneck is per-shard insert rate (a database can't do a million inserts per second anyway), while having many logical shards gives room to grow.
7. A Snowflake-style generator in Python
import threading
import time
class Snowflake:
"""64-bit IDs: 1 unused sign bit | 41 bits ms since EPOCH | 10 bits worker | 12 bits sequence."""
EPOCH_MS = 1288834974657 # custom epoch (Twitter's: 2010-11-04T01:42:54.657Z)
WORKER_BITS, SEQ_BITS = 10, 12
MAX_WORKER = (1 << WORKER_BITS) - 1 # 1023
MAX_SEQ = (1 << SEQ_BITS) - 1 # 4095
MAX_TIME = (1 << 41) - 1
MAX_BACKWARD_MS = 10 # tolerate tiny clock regressions by waiting
def __init__(self, worker_id: int):
if not 0 <= worker_id <= self.MAX_WORKER:
raise ValueError("worker_id must be in [0, 1023]")
self.worker_id = worker_id # must be unique per running generator!
self.last_ms = -1
self.seq = 0
self._lock = threading.Lock()
def _now(self) -> int:
return time.time_ns() // 1_000_000 - self.EPOCH_MS
def next_id(self) -> int:
with self._lock:
now = self._now()
if now < self.last_ms: # clock moved backwards
drift = self.last_ms - now
if drift > self.MAX_BACKWARD_MS:
raise RuntimeError(f"clock moved back {drift} ms; refusing to generate")
while now < self.last_ms: # small drift: wait it out
time.sleep((self.last_ms - now) / 1000)
now = self._now()
if now == self.last_ms:
self.seq = (self.seq + 1) & self.MAX_SEQ
if self.seq == 0: # 4,096 IDs used this ms
while now <= self.last_ms: # spin until the next millisecond
now = self._now()
else:
self.seq = 0 # new millisecond: restart sequence
if now > self.MAX_TIME:
raise RuntimeError("41-bit timestamp exhausted; pick a new epoch")
self.last_ms = now
return (now << (self.WORKER_BITS + self.SEQ_BITS)) | (self.worker_id << self.SEQ_BITS) | self.seq
@classmethod
def decode(cls, id_: int) -> dict:
return {
"unix_ms": (id_ >> (cls.WORKER_BITS + cls.SEQ_BITS)) + cls.EPOCH_MS,
"worker_id": (id_ >> cls.SEQ_BITS) & cls.MAX_WORKER,
"sequence": id_ & cls.MAX_SEQ,
}
gen = Snowflake(worker_id=37)
ids = [gen.next_id() for _ in range(5)]
assert ids == sorted(ids) and len(set(ids)) == 5 # unique and increasing on one worker
print(ids[0], Snowflake.decode(ids[0]))
Implementation notes:
- Worker IDs must be unique among live generators. Assign from deployment config, a Kubernetes StatefulSet ordinal, or lease them from ZooKeeper/etcd (ephemeral nodes released when a process dies). Two processes sharing a worker ID will eventually collide — the most common real-world Snowflake bug.
- The lock makes it thread-safe within one process; across processes, each needs its own worker ID.
- Embed vs service: run the generator as a library inside every service (no network hop — preferred) or as a small HA service that hands out IDs (simpler worker-ID management, but a dependency).
- JSON and JavaScript: JS numbers are doubles and represent integers exactly only up to 253. A 64-bit ID sent as a JSON number gets silently rounded in browsers. Send IDs as strings (Twitter's API added
id_strfor exactly this reason).
8. How Instagram uses ID generation
- When user 31341 creates a post, the service computes the user's logical shard and inserts the row there; the database assigns the ID via
next_id(). - That single ID then flows everywhere: object-storage keys for media renditions (with a hash prefix for load spreading — see Object Storage), the "post created" Kafka event (see Message Queues), the follower timelines in Redis (see News Feed), the search index and the URL.
- Feed pagination uses the ID as a cursor: "next page = posts with ID < last_seen_id", which is "older than" thanks to time-ordering (see API Design).
- Any service holding an ID can route reads to the owning shard without a lookup.
instagram.com/p/<code>/) is a base64-style encoding of the numeric media ID — a compact, URL-friendly representation of the same number.9. Trade-offs & common pitfalls
✅ Snowflake / Instagram-style IDs
- 64-bit: compact indexes and fast comparisons
- Time-sortable: B-tree friendly, free "newest first", cursor pagination
- No coordination per ID, no SPOF, millions per second
- Self-describing: creation time (and shard) from the ID
⚠️ Costs
- Depends on reasonably correct clocks; backwards jumps need handling
- Worker/shard IDs must be assigned and kept unique
- Only roughly ordered across machines
- Leaks creation time and approximate volume
- Fixed bit budget: lifetime and max shards are baked in
Common pitfalls
- Duplicate worker IDs after autoscaling or copy-pasted config → silent collisions. Lease IDs and fail fast on conflict.
- Using
now()inside a transaction (frozen time) or a non-monotonic clock source. - Sending 64-bit IDs as JSON numbers to JavaScript clients → rounding. Use strings.
- Using IDs as Redis sorted-set scores → precision loss above 253 (see Caching).
- Relying on ID order for strict causality across machines — it's only k-sorted.
- Hot prefixes when time-ordered IDs are used directly as prefixes of object-storage keys or as the first part of a range-partitioned key — all new writes hit the same partition. Hash first.
- Exposing sequential IDs where enumeration matters (private resources). Time-based IDs are hard to guess exactly but not secret; authorization must not depend on ID secrecy.
- Forgetting the epoch end — pick an epoch near your launch, and note the signed-bit caveat.
10. Real-world technologies
| Technology | Format | Use when |
|---|---|---|
| Twitter Snowflake (and many open-source ports) | 64-bit: 41 time / 10 machine / 12 seq | You need compact, sortable IDs at very high rates across many services |
| Sonyflake | 64-bit: 39 time (10 ms units) / 8 seq / 16 machine | Many machines, lower per-machine rate, longer lifetime (~174 years) |
| Instagram-style PL/pgSQL function | 64-bit: 41 time / 13 shard / 10 seq | Sharded PostgreSQL where IDs should encode the shard |
| UUID v7 (RFC 9562), ULID, KSUID | 128-bit (KSUID 160-bit) time-ordered | Decentralised generation, no worker IDs, 128 bits acceptable |
| UUID v4 | 128-bit random | Unguessable tokens, idempotency keys, low-volume tables — not hot primary keys |
| MongoDB ObjectId | 96-bit: 4-byte seconds + 5-byte random + 3-byte counter | Default document IDs in MongoDB |
| Flickr ticket servers, Meituan Leaf (segment mode), Baidu UidGenerator | 64-bit from DB sequences / blocks | Central allocation with batching is acceptable; want dense IDs |
| DB sequences / IDENTITY | 64-bit dense | Single database or per-shard local IDs that never leave the shard |
11. Interview corner
Design a unique ID generator for Instagram posts.
Requirements: unique, 64-bit, roughly time-ordered, >100K/s, no SPOF, no per-ID coordination. Use a Snowflake-style layout: 41 bits of milliseconds since a custom epoch (~69 years), N bits identifying the generator, and the remaining bits as a per-millisecond sequence. Instagram's variant puts 13 bits of logical shard ID and a 10-bit sequence and generates IDs inside each PostgreSQL shard via a PL/pgSQL default, so the ID also tells you where the row lives. Handle clock regressions by tracking the last timestamp and waiting or refusing; assign worker/shard IDs uniquely at startup; return IDs as strings to JS clients.
Why not just use UUIDs?
UUID v4 is 128 bits (2× storage in every index and foreign key) and random, so inserts scatter across the B-tree, causing page splits, poor cache locality and much slower inserts once the index exceeds RAM; you also lose free time ordering and need an extra created_at index. UUID v7/ULID fix ordering but are still 128 bits and carry no routing information. They're good choices when you don't control the scale or can't assign worker IDs.
What happens if the clock goes backwards on a Snowflake node?
If the generator naively used the new, smaller time, it could reissue (time, worker, sequence) combinations already used → duplicates. The generator remembers the last timestamp; on a small regression it waits until real time passes it, on a large one it refuses to generate and alerts (the node is removed from rotation). Operationally, slew rather than step NTP corrections and use leap-second smearing. Some implementations keep issuing IDs with the last timestamp and an incremented sequence ("borrowing" future time).
How does putting the shard ID in the post ID help?
Routing becomes arithmetic: (id >> 10) & 8191 gives the logical shard, and a tiny cached map gives the physical server — no directory service, no extra hop, no extra failure point, for every read of every object. It also enables co-locating related data on the same shard, and because it's a logical shard, moving it to new hardware changes one map entry instead of any IDs.
How many IDs per second can Instagram's scheme produce, and is that enough?
1,024 per millisecond per logical shard = ~1M/s per shard, and with up to 8,192 shards ~8.4B/s theoretical. Real demand is maybe tens of thousands of inserts per second overall, and a single PostgreSQL shard can't do anywhere near 1M inserts/s, so the sequence bits are never the bottleneck. If a shard did exceed 1,024 in one ms, the sequence would wrap and could collide — guarded by the primary key constraint (retry).
Are Snowflake IDs strictly ordered? Can I use them to order events?
Only roughly (k-sorted). Within a single generator they increase strictly; across generators ordering is only as good as clock synchronisation (typically ~1 ms). They're fine for "newest first" feeds and pagination but not for causal ordering of events — use a single partition's log offsets, version numbers, or logical/hybrid clocks for that.
How would you assign worker IDs in a Kubernetes deployment that autoscales?
Options: a StatefulSet ordinal (stable but limits scaling patterns); lease a worker ID from etcd/ZooKeeper with an ephemeral, TTL'd key so a dead pod's ID is freed and two live pods can never hold the same one; or allocate from a DB table with a lease timestamp. On startup the generator must acquire a lease before serving, and stop generating if it loses the lease.
12. Check your understanding
Decode the Instagram-style ID 4185136929178974185 (epoch 2011-01-01T00:00:00Z = 1293840000000 ms).
id >> 23 = 498,907,200,000 ms after the epoch → 1,792,747,200,000 Unix ms = 2026-10-23 09:20:00 UTC; (id >> 10) & 8191 = shard 1341; id & 1023 = sequence 1001.
How long does a 41-bit millisecond timestamp last, and why use a custom epoch?
241 ms ≈ 69.7 years. Counting from 1970 would have used up ~40 of those years before the company even existed; a custom epoch near launch maximises remaining lifetime.
Why do random UUID primary keys slow down inserts once the table is large?
Each insert goes to a random leaf page of the B-tree. When the index is bigger than RAM, those pages are often not cached → random disk reads, plus frequent page splits that leave pages half-empty, bloating the index and write-ahead log. Time-ordered keys always insert at the right edge, which stays hot in memory.
A Snowflake worker receives 10,000 ID requests within one millisecond. What happens?
The 12-bit sequence allows 4,096 IDs per ms. After 4,096, the sequence wraps to 0 and the generator waits for the next millisecond. The 10,000 requests take ~3 ms. No duplicates — just a tiny delay.
Two pods accidentally share worker ID 37. What can go wrong and how would you detect it?
If both generate an ID in the same millisecond with the same sequence number, they produce identical IDs — e.g. two posts with one ID, one overwriting the other in caches. Detect via unique-constraint violations, and prevent by leasing worker IDs from etcd/ZooKeeper.
Your web client shows post 4185136929178974185 as 4185136929178974000 and opens the wrong post. Why?
The ID was sent as a JSON number; JavaScript stores numbers as 64-bit doubles that are exact only up to 253 ≈ 9×1015, so the 19-digit ID is rounded. Send IDs as strings.
Instagram moved a logical shard to a new physical server. Do any post IDs change?
No. IDs contain the logical shard number. Only the logical → physical map entry changes, which every app server picks up from configuration.
- Good distributed IDs are unique, 64-bit, roughly time-sortable, fast and coordination-free.
- Auto-increment and ticket servers centralise; UUID v4 is random and bloats indexes; UUID v7/ULID are time-ordered but 128-bit.
- Snowflake = 41-bit ms timestamp + 10-bit machine + 12-bit sequence (4,096/ms/machine, ~69 years).
- Instagram = 41-bit ms timestamp + 13-bit logical shard + 10-bit sequence, generated in PostgreSQL — the ID itself routes to its shard.
- Handle clock regressions (wait or refuse), keep worker IDs unique, and send IDs to JS as strings.
Related lessons
- Sharding — logical vs physical shards and why the shard ID in the ID matters
- Database Indexing — B-trees and why key order affects insert performance
- Databases: SQL vs NoSQL — PostgreSQL and Cassandra, where these IDs live
- News Feed — time-sortable IDs as feed cursors
- API Design — cursor-based pagination with IDs
- Object Storage — avoiding hot prefixes with time-ordered IDs
- CAP & Consistency — why clock-based ordering is only approximate