System design · Lesson 56 of 62 · about 75 min
Design a distributed ID generator
Generate unique, roughly time-ordered 64-bit IDs on many machines without coordination: compare UUIDs, database sequences, ticket servers, Snowflake and ULID, handle clock skew, and build a Snowflake service in Go.
On this page · 9 sections
1. Requirements#
- Unique across all machines, forever.
- 64-bit integers (fit in
int64, fast to index, compact in URLs). - Roughly sortable by creation time.
- High throughput: tens of thousands of IDs per second per machine, with no network call per ID.
- Available even if other machines or a central database are down.
2. The options#
| Approach | Size | Sortable | Coordination | Notes |
|---|---|---|---|---|
| database auto-increment | 64 bit | yes | every insert hits one DB | simple; a single point of failure and a bottleneck |
| ticket server with ranges | 64 bit | roughly | one query per block of IDs | Flickr's approach; gaps after restarts |
| UUIDv4 (random) | 128 bit | no | none | huge space, but random inserts hurt B-tree indexes |
| UUIDv7 | 128 bit | yes (ms) | none | timestamp + random; the modern default when 128 bits is fine |
| ULID | 128 bit | yes (ms) | none | like UUIDv7 with a sortable Base32 text form |
| Snowflake | 64 bit | yes (ms) | machine ID assigned once | timestamp + machine + sequence |
3. Snowflake's layout#
| Bits | Field | Range |
|---|---|---|
| 1 | sign, always 0 | keeps the ID positive in int64 |
| 41 | milliseconds since a custom epoch | about 69.7 years |
| 10 | machine ID | 1,024 machines |
| 12 | sequence within the millisecond | 4,096 IDs per ms per machine |
Step through the packing with your own numbers:
Because the timestamp is in the highest bits, comparing two IDs numerically compares their creation times (to the millisecond, and assuming machines' clocks agree closely).
4. Deep dives#
- Assigning machine IDs: from configuration (an environment variable per deployment), or claimed dynamically through a coordination service (etcd, ZooKeeper, or a database row with a unique constraint and a lease). Two machines with the same ID will produce duplicates, so this is the one thing that must be coordinated.
- Sequence overflow: more than 4,096 IDs in one millisecond on one machine means waiting for the next millisecond. That is a cap of about 4 million IDs per second per machine.
- Clocks going backward: NTP can step the clock back. If the generator issued IDs at time T and the clock now says T-5, it must not use T-5 again. Either wait until the clock catches up (for small jumps) or return an error and alert (for large ones).
- Information leakage: IDs reveal creation time and roughly how many IDs a machine issues. If that matters, do not expose raw IDs publicly.
5. UUIDv7 in a few lines#
When 128 bits is acceptable, UUIDv7 needs no machine IDs at all: 48 bits of Unix milliseconds, then version and variant bits, then 74 random bits.
package main
import (
"crypto/rand"
"encoding/binary"
"fmt"
"time"
)
func uuidv7(now time.Time) [16]byte {
var u [16]byte
rand.Read(u[:]) // random bits everywhere first
ms := uint64(now.UnixMilli())
binary.BigEndian.PutUint64(u[0:8], ms<<16|uint64(binary.BigEndian.Uint16(u[6:8]))) // 48-bit time in front
u[6] = 0x70 | u[6]&0x0f // version 7
u[8] = 0x80 | u[8]&0x3f // RFC 9562 variant
return u
}
func format(u [16]byte) string {
return fmt.Sprintf("%x-%x-%x-%x-%x", u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
}
func main() {
t := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
a, b := uuidv7(t), uuidv7(t.Add(time.Millisecond))
fmt.Println("timestamp prefix:", format(a)[:13])
fmt.Println("version:", format(a)[14:15], " later sorts after earlier:", format(b) > format(a))
ms := binary.BigEndian.Uint64(a[0:8]) >> 16
fmt.Println("decoded time:", time.UnixMilli(int64(ms)).UTC())
}timestamp prefix: 01a13522-4200
version: 7 later sorts after earlier: true
decoded time: 2026-10-13 12:00:00 +0000 UTCBuild it: a Snowflake ID service#
You will build a generator package that survives concurrency, sequence overflow and backward clocks, an HTTP service that issues and decodes IDs, and a page that shows each ID's bit fields.
idgen/
├── go.mod
├── main.go GET /api/ids?n=5, GET /api/ids/{id}
├── main_test.go
├── internal/snowflake/snowflake.go
├── internal/snowflake/snowflake_test.go
└── web/index.htmlStep 1: module
mkdir idgen && cd idgen && go mod init idgen
mkdir -p internal/snowflake webmodule idgen
go 1.24Step 2: the generator
// Package snowflake generates 64-bit, time-ordered unique IDs.
package snowflake
import (
"errors"
"sync"
"time"
)
const (
machineBits = 10
sequenceBits = 12
maxMachine = 1<<machineBits - 1
maxSequence = 1<<sequenceBits - 1
// MaxBackwardWait is the largest backward clock jump the generator waits out instead of failing.
MaxBackwardWait = 10 * time.Millisecond
)
// Epoch is the custom epoch (2024-01-01 UTC); 41 bits of milliseconds last until about 2093.
var Epoch = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
var ErrClockBackwards = errors.New("snowflake: clock moved backwards")
// Generator issues IDs for one machine. It is safe for concurrent use.
type Generator struct {
mu sync.Mutex
machine int64
lastMS int64
sequence int64
now func() time.Time
sleep func(time.Duration)
}
// New returns a generator for machineID (0-1023). now and sleep may be nil (real clock).
func New(machineID int, now func() time.Time, sleep func(time.Duration)) (*Generator, error) {
if machineID < 0 machineID > maxMachine {
return nil, errors.New("snowflake: machine ID must be 0-1023")
}
if now == nil {
now = time.Now
}
if sleep == nil {
sleep = time.Sleep
}
return &Generator{machine: int64(machineID), lastMS: -1, now: now, sleep: sleep}, nil
}
func (g *Generator) millis() int64 { return g.now().Sub(Epoch).Milliseconds() }
// Next returns a new unique ID.
func (g *Generator) Next() (int64, error) {
g.mu.Lock()
defer g.mu.Unlock()
ms := g.millis()
if ms < g.lastMS {
back := time.Duration(g.lastMS-ms) * time.Millisecond
if back > MaxBackwardWait {
return 0, ErrClockBackwards
}
g.sleep(back) // a small NTP correction: wait it out
if ms = g.millis(); ms < g.lastMS {
return 0, ErrClockBackwards
}
}
if ms == g.lastMS {
g.sequence = (g.sequence + 1) & maxSequence
if g.sequence == 0 { // 4096 IDs already issued this millisecond
for ms <= g.lastMS {
g.sleep(time.Millisecond / 10)
ms = g.millis()
}
}
} else {
g.sequence = 0
}
g.lastMS = ms
return ms<<(machineBits+sequenceBits) | g.machine<<sequenceBits | g.sequence, nil
}
// Parts is a decoded ID.
type Parts struct {
Time time.Time `json:"time"`
Machine int64 `json:"machine"`
Sequence int64 `json:"sequence"`
}
// Decode splits an ID back into its fields.
func Decode(id int64) Parts {
return Parts{
Time: Epoch.Add(time.Duration(id>>(machineBits+sequenceBits)) * time.Millisecond).UTC(),
Machine: id >> sequenceBits & maxMachine,
Sequence: id & maxSequence,
}
}The tests use a fake clock that only moves when the test says so, which makes overflow and backward jumps reproducible:
package snowflake
import (
"errors"
"sync"
"testing"
"time"
)
type clock struct {
mu sync.Mutex
t time.Time
}
func (c *clock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *clock) Sleep(d time.Duration) { // sleeping just advances fake time
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
func newTest(t *testing.T, machine int) (*Generator, *clock) {
t.Helper()
c := &clock{t: Epoch.Add(time.Hour)}
g, err := New(machine, c.Now, c.Sleep)
if err != nil {
t.Fatal(err)
}
return g, c
}
func TestDecodeRoundTrip(t *testing.T) {
g, c := newTest(t, 37)
id, _ := g.Next()
p := Decode(id)
if p.Machine != 37 p.Sequence != 0p.Time.Equal(c.Now()) {
t.Fatalf("Decode = %+v", p)
}
}
func TestOrderedAndUniqueAcrossOverflow(t *testing.T) {
g, _ := newTest(t, 1)
prev := int64(-1)
for i := range 10_000 { // more than 4096 in the same fake millisecond forces waits
id, err := g.Next()
if err != nil {
t.Fatal(err)
}
if id <= prev {
t.Fatalf("id %d at step %d not increasing", id, i)
}
prev = id
}
}
func TestMachinesNeverCollide(t *testing.T) {
g1, _ := newTest(t, 1)
g2, _ := newTest(t, 2)
a, _ := g1.Next()
b, _ := g2.Next()
if a == b {
t.Fatal("same ID from different machines")
}
}
func TestClockBackwards(t *testing.T) {
g, c := newTest(t, 5)
g.Next()
c.t = c.t.Add(-3 * time.Millisecond) // small jump: waited out
if _, err := g.Next(); err != nil {
t.Fatalf("small backward jump: %v", err)
}
c.t = c.t.Add(-time.Second) // big jump: refuse
if _, err := g.Next();errors.Is(err, ErrClockBackwards) {
t.Fatalf("want ErrClockBackwards, got %v", err)
}
}
func TestConcurrentUnique(t *testing.T) {
g, err := New(9, nil, nil) // the real clock
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
seen := map[int64]bool{}
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for range 2000 {
id, err := g.Next()
if err != nil {
t.Error(err)
return
}
mu.Lock()
if seen[id] {
t.Errorf("duplicate %d", id)
}
seen[id] = true
mu.Unlock()
}
}()
}
wg.Wait()
}Step 3: the HTTP service
IDs are returned as strings in JSON. JavaScript numbers are 64-bit floats with 53 bits of integer precision, so a raw 64-bit ID would be silently rounded in the browser. The ,string struct tag option does the conversion.
package main
import (
_ "embed"
"encoding/json"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"idgen/internal/snowflake"
)
//go:embed web/index.html
var indexHTML []byte
type idOut struct {
ID int64 `json:"id,string"` // as a JSON string: JavaScript cannot hold 64-bit integers exactly
snowflake.Parts
}
func newServer(g *snowflake.Generator) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
})
mux.HandleFunc("GET /api/ids", func(w http.ResponseWriter, r *http.Request) {
n := 1
if s := r.URL.Query().Get("n"); s != "" {
v, err := strconv.Atoi(s)
if err != nil v < 1 v > 1000 {
http.Error(w, "n must be 1-1000", http.StatusBadRequest)
return
}
n = v
}
out := make([]idOut, 0, n)
for range n {
id, err := g.Next()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
out = append(out, idOut{id, snowflake.Decode(id)})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
})
mux.HandleFunc("GET /api/ids/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil id < 0 {
http.Error(w, "id must be a positive 64-bit integer", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(idOut{id, snowflake.Decode(id)})
})
return mux
}
func main() {
machine, err := strconv.Atoi(os.Getenv("MACHINE_ID")) // each instance needs a unique one
if err != nil {
machine = 0
}
g, err := snowflake.New(machine, nil, nil)
if err != nil {
slog.Error("generator", "err", err)
os.Exit(1)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
slog.Info("listening", "port", port, "machine", machine)
srv := &http.Server{Addr: ":" + port, Handler: newServer(g), ReadHeaderTimeout: 5 * time.Second}
if err := srv.ListenAndServe(); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}package main
import (
"encoding/json"
"net/http/httptest"
"strconv"
"testing"
"time"
"idgen/internal/snowflake"
)
func TestIssueAndDecode(t *testing.T) {
now := snowflake.Epoch.Add(48 * time.Hour)
g, _ := snowflake.New(7, func() time.Time { return now }, func(d time.Duration) { now = now.Add(d) })
h := newServer(g)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/ids?n=3", nil))
var ids []struct {
ID string `json:"id"`
Machine int64 `json:"machine"`
Sequence int64 `json:"sequence"`
}
if err := json.NewDecoder(rec.Body).Decode(&ids); err != nil len(ids) != 3 {
t.Fatalf("decode: %v, %d ids", err, len(ids))
}
if ids[2].Sequence != 2 ids[0].Machine != 7 {
t.Errorf("unexpected fields: %+v", ids)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/ids/"+ids[1].ID, nil))
var one struct {
ID string `json:"id"`
Time time.Time `json:"time"`
}
json.NewDecoder(rec.Body).Decode(&one)
if one.ID != ids[1].IDone.Time.Equal(now) {
t.Errorf("decode endpoint: %+v", one)
}
if _, err := strconv.ParseInt(one.ID, 10, 64); err != nil {
t.Error("id must be a decimal string")
}
for _, bad := range []string{"/api/ids?n=0", "/api/ids?n=5000", "/api/ids/abc"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", bad, nil))
if rec.Code != 400 {
t.Errorf("%s: status %d, want 400", bad, rec.Code)
}
}
}Step 4: the page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snowflake IDs</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; font: 13px monospace; }
td, th { padding: .35rem .5rem; border-bottom: 1px solid #ddd; text-align: left; }
.bits span { padding: 0 2px; border-radius: 3px; }
.t { background: #fbf1c7; } .m { background: #e0f1f7; } .s { background: #e1f3e8; }
button, input { padding: .45rem .7rem; border-radius: 8px; border: 1px solid #999; }
</style>
</head>
<body>
<h1>Snowflake ID generator</h1>
<p><button id="gen">Generate 5 IDs</button>
<input id="id" placeholder="paste an ID to decode"> <button id="dec">Decode</button></p>
<table><thead><tr><th>id</th><th>time (UTC)</th><th>machine</th><th>seq</th></tr></thead><tbody id="rows"></tbody></table>
<p class="bits" id="bits"></p>
<script>
function bits(id) {
const b = BigInt(id).toString(2).padStart(64, "0");
return `<span class="t">${b.slice(0, 42)}</span><span class="m">${b.slice(42, 52)}</span><span class="s">${b.slice(52)}</span>`;
}
function show(list) {
const tbody = document.getElementById("rows");
for (const x of list) {
const tr = document.createElement("tr");
for (const v of [x.id, x.time, x.machine, x.sequence]) {
const td = document.createElement("td");
td.textContent = v;
tr.append(td);
}
tr.onclick = () => document.getElementById("bits").innerHTML = bits(x.id);
tbody.prepend(tr);
}
if (list.length) document.getElementById("bits").innerHTML = bits(list[list.length - 1].id);
}
document.getElementById("gen").onclick = async () => show(await (await fetch("/api/ids?n=5")).json());
document.getElementById("dec").onclick = async () => {
const res = await fetch("/api/ids/" + encodeURIComponent(document.getElementById("id").value.trim()));
if (res.ok) show([await res.json()]);
else alert(await res.text());
};
</script>
</body>
</html>BigInt in the page parses the string ID exactly, so the bit display is correct for all 64 bits. Click a row to see its timestamp (yellow), machine (blue) and sequence (green) bits.
Step 5: run several machines
go test -race ./...
MACHINE_ID=1 PORT=8081 go run . &
MACHINE_ID=2 PORT=8082 go run . &
curl -s 'localhost:8081/api/ids?n=2'; echo
curl -s 'localhost:8082/api/ids?n=2'; echoThe two instances issue different IDs in the same millisecond because their machine bits differ. In production, assign MACHINE_ID per replica (a Kubernetes StatefulSet ordinal works well) or claim it from a database with a unique constraint and a heartbeat.
Summary#
- Choose by needs: auto-increment for one database, UUIDv7 when 128 bits is fine, Snowflake for compact sortable 64-bit IDs.
- Snowflake packs 41 bits of time, 10 of machine and 12 of sequence; only the machine ID needs coordination.
- Wait on sequence overflow, refuse or wait on backward clocks, and never let two machines share an ID.
- Send 64-bit IDs to browsers as strings.
- The build tests every edge case with a fake clock that the test controls.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.Why are random UUIDv4 primary keys often slow in B-tree indexes?
Q2.In a Snowflake ID, what guarantees two machines never produce the same ID in the same millisecond?
Q3.What should a Snowflake generator do when its 12-bit sequence overflows within one millisecond?
Q4.The system clock jumps backward by 5 ms (an NTP correction). A safe generator:
Q5.How does a ticket-server range allocator reduce database load?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
- Snowflake with datacenter bitsbuild
Split the 10 machine bits into 5 datacenter + 5 worker bits. Add both to the decode endpoint and the page.
- Machine IDs from leasesbuild
Instead of a MACHINE_ID environment variable, claim a free machine ID by inserting into a
machinestable with a unique constraint and a heartbeat column, and release stale claims after 1 minute. - UUIDv7 packagebuild
Turn the UUIDv7 function from this lesson into a package with Parse, String and Time methods, and fuzz-test Parse.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.