Choosing the next ID  / field guide
Practitioner field guide · 2026-09-27

There is no neutral identifier

Every row a system creates gets a name, and the naming scheme is picked on day one, when none of its pressures are visible. This guide reads the repositories of GitLab, Rails, PostgreSQL, Twitter, Mastodon, Nextcloud, CockroachDB and the IETF's own UUID revision to reconstruct how identifiers are minted in production, and the four distinct ways the choice fails: the numbers run out, two writers issue the same one, the ordered ones pile onto one page or one shard, and the ID itself tells strangers things you never meant to publish.

24 primary sources 16 organisations 7 incidents and near-misses Evidence through Sept 2026 Read: ~22 min
01

The territory

A problem that predates every database it lives in: give each new thing a name that is unique across concurrent writers, cheap to mint at write rate, orderable enough for the storage engine, and safe to show to strangers.

2.147bn
The ceiling every signed 32-bit key shares: 2,147,483,647 rows, then writes fail
54.42%
How much of that ceiling GitLab's ci_builds table had consumed when measured in April 2021
10k/s
IDs per second per process, under 2 ms, that Twitter's Snowflake was required to mint in 2010
10×
The index-locality gap RFC 9562 attributes to time-ordered keys over random inserts: "one order of magnitude or more"

State the problem without naming a technology: a system that accepts writes must give each new record a name that no other record carries, without making writers wait for each other, and the name will then live in indexes, URLs, caches, foreign keys and other people's client code for the lifetime of the data. That is the whole problem. It looks like a one-line schema decision, and it is one of the few schema decisions that cannot be changed cheaply later: Nextcloud's admin manual puts the cost of widening the column afterwards at "several hours or even days" of maintenance, and GitLab's online version of the same change is a multi-year programme of triggers and backfills.

The public record on this problem is unusually good, because so much of it was argued in the open. Twitter published its generator's requirements in the Snowflake repository in 2010. Mastodon's switch away from sequential IDs is a merged pull request with the dissent still attached. GitLab's near-miss with a 32-bit key is a public issue with the exhaustion forecast in it. And the IETF's 2024 revision of the UUID standard, RFC 9562, was drafted in a public GitHub repository whose background section catalogues roughly sixteen non-standard schemes (ULID, KSUID, Snowflake, Sonyflake, MongoDB's ObjectID and others) that industry built while the standard stood still. The standards body did not lead this field; it ratified, fourteen years after Snowflake, what production systems had already converged on.

Figure 1 · Three places uniqueness can live, and what each one costs

Who guarantees the next ID is unique?

The store issues it
sequence / auto-increment

Each node mints it
timestamp + node id + counter

Chance guarantees it
large random value

Costs: one issuing point,
a fixed-width ceiling,
IDs that count your rows

Costs: node-id assignment,
trust in the clock,
IDs that carry a timestamp

Costs: 128+ bits per key,
random index placement,
no time ordering

Who guarantees the next ID is unique?

The store issues it
sequence / auto-increment

Each node mints it
timestamp + node id + counter

Chance guarantees it
large random value

Costs: one issuing point,
a fixed-width ceiling,
IDs that count your rows

Costs: node-id assignment,
trust in the clock,
IDs that carry a timestamp

Costs: 128+ bits per key,
random index placement,
no time ordering

Every scheme in production is one of these three families; the coordination never disappears, it moves. Sources: Snowflake README, KSUID README, GitLab #215017.
Diagram source
The finding that surprised us

The two halves of the database industry give opposite advice about the same property, and both are right. The PostgreSQL world spent a decade making identifiers more ordered, because random UUIDs touch every index leaf page and evict the cache: Tomas Vondra's sequential-uuids extension (2018), then UUIDv7 itself, whose RFC claims an order-of-magnitude locality gain. The distributed-SQL world builds machinery to make ordered keys less ordered: CockroachDB's hash-sharded indexes exist because, in its own words, "workloads involving sequential keys can have major performance problems." Time-ordering is not good or bad; it is good exactly when the storage engine appends and bad exactly when the storage engine range-partitions. The ID must be chosen with the store, not before it.

Scope. This guide covers surrogate identifiers for rows and objects: how they are minted, sized, ordered and exposed. It deliberately does not cover natural and business keys, transaction-ID wraparound (a database-internal counter with its own incident history, indexed in the same postmortem collection cited here), URL-shortener keyspaces, trace and span IDs, or content-addressed hashes. One honest limit of the evidence base: this session's network policy reaches code hosts but not engineering-blog, paper or talk hosts, so the wall below is built from issues, commits, specs and runbooks, and three well-known blog-hosted postmortems (Basecamp, GitHub, Strava) enter via the danluu/post-mortems collection entry that indexes them rather than as direct fetches.

02

How it is actually built

The node-local generator is the same machine everywhere it appears: a timestamp, a node number, a counter, packed into a fixed budget of bits. Every design choice is a reallocation of that budget, and every field fails differently.

Start with the archetype. Twitter needed IDs for tweets as it moved off MySQL, and the Snowflake README states the constraint that created the whole family: "There is no sequential id generation facility in Cassandra, nor should there be." The requirements were a minimum of 10,000 IDs per second per process, under 2 ms, with no coordination between machines, and IDs that are "k-sorted" (roughly time-ordered, within a bound the README puts at about one second). The layout that satisfies this is 41 bits of milliseconds since a custom epoch, 10 bits of machine ID and 12 bits of per-millisecond sequence, in one 64-bit integer. Everything since is a re-cut of those three fields.

Figure 2 · One machine, three budgets: how the 64 bits get spent

Mastodon, 2017 (in Postgres)

48 bits time
1 ms units

16 bits sequence
65,536 per ms

Sony Sonyflake, 2015

39 bits time
10 ms units, 174 years

8 bits sequence
256 per 10 ms

16 bits machine
65,536 nodes

Twitter Snowflake, 2010

41 bits time
1 ms units, 69 years

10 bits worker
1,024 nodes

12 bits sequence
4,096 per ms

Mastodon, 2017 (in Postgres)

48 bits time
1 ms units

16 bits sequence
65,536 per ms

Sony Sonyflake, 2015

39 bits time
10 ms units, 174 years

8 bits sequence
256 per 10 ms

16 bits machine
65,536 nodes

Twitter Snowflake, 2010

41 bits time
1 ms units, 69 years

10 bits worker
1,024 nodes

12 bits sequence
4,096 per ms

Sony bought 174 years of lifetime and 65,536 machines by paying with generation rate: 256 IDs per 10 ms per instance against Snowflake's 4,096 per 1 ms. Sources: Snowflake README, 2010; Sonyflake README; Mastodon PR #4801, 2017.
Diagram source

Read the three fields as three liabilities, because that is how they fail in production.

The timestamp field is a bet on the clock. The Snowflake README is blunt about the posture: "If your clock is running fast and NTP tells it to repeat a few milliseconds, snowflake will refuse to generate ids until a time that is after the last time we generated an id." Refusal, not reuse: the generator chooses unavailability over duplicates, which means a clock step backwards is a write outage on that node. PostgreSQL's 2024 implementation of UUIDv7 takes the other branch: the commit stores a 12-bit sub-millisecond fraction in the space RFC 9562 calls rand_a, and the commit message states that generation stays monotonic "within the same backend even when the system clock goes backward." Same problem, opposite remedies, and the difference matters on the day NTP misbehaves.

The node field is a small distributed-systems problem you now operate. Ten bits of worker ID means something must hand out 1,024 distinct numbers and never give the same one to two live processes; the k-sortedness and the uniqueness both depend on it. None of the READMEs in this corpus describe their assignment mechanism in detail, which is itself a finding: the hard operational part of the snowflake family is the part the repositories are quietest about. The 128-bit family exists to delete this field entirely. Segment's KSUID README makes the trade explicit: 128 bits of randomness, "64 times larger than the 122 bits" of a UUIDv4, so that no worker registry is needed at all; the price is 160-bit IDs (20 bytes binary, 27 characters as text) instead of 64.

The sequence field is the rate limiter. 4,096 IDs per millisecond per worker for Snowflake, 256 per 10 ms for Sonyflake, 65,536 per millisecond per Postgres instance in Mastodon's layout. The ULID spec shows what happens at the boundary of the equivalent field: within one millisecond the random component is incremented "by 1 bit in the least significant bit position (with carrying)", and if the counter would overflow, "the generation will fail." Every generator in this family has a ceiling per tick; the only question is whether it blocks, fails or silently reuses when it hits it. RFC 9562 standardised the three respectable answers as its three monotonicity methods: a dedicated counter after the timestamp, a monotonic random increment, or extra clock precision in the leading random bits.

The second architecture: the one you build when the first one was wrong

There is a fourth component that belongs in the reference architecture, although nobody plans it: the widening machinery. GitLab's merge request !49778 (December 2020) is the canonical shape, built to convert int4 primary keys to bigint on tables that cannot stop: add a shadow bigint column, install a trigger that mirrors every write into it, backfill the history in batches from background jobs, then swap the columns. It is a dual-write system bolted onto your own primary key, it explicitly targets "DOWNTIME = false", and it is not fast: the programme that started with that MR was still producing cleanup-migration failures in self-managed upgrades in June 2024. Nextcloud, facing the same conversion with less operational leverage over its installations, ships it as an offline command (occ db:convert-filecache-bigint) and warns that it "can take several hours or even days." The transfer for an architect: the day-one width decision is the only cheap one you will ever get on this column, which is exactly why Rails changed the framework default to bigint in PR #26266 with the one-line justification "Friends don't let friends use INT as a primary key."

03

The decisions that matter

Four forks, each with the condition that flips it. The recurring pattern: every desirable property of an identifier is paid for in a different currency, and the right choice depends on which currency you happen to be rich in.

Who mints the ID: the database, or the nodes?

Chosen
  • Store-issued sequences, widened to 64 bits: Rails made bigint the framework default in 2016 (PR #26266); GitLab and Nextcloud run on them at their largest scale
  • One issuing point means uniqueness is structural, not probabilistic
Rejected
  • Twitter rejected store-issued IDs when the store stopped being able to issue them: "There is no sequential id generation facility in Cassandra" (Snowflake README, 2010)
Flips when
  • Writes leave the single store: multiple regions, a store without sequences, or offline clients that must name records before they reach the server
  • Until then, the sequence plus bigint is the least machinery that works

64 bits with coordination, or 128+ bits without?

Chosen (by the coordination-averse)
  • Segment picked 160-bit KSUIDs: 128 random bits are "64 times larger than the 122 bits" of UUIDv4, so no worker registry exists to misconfigure (KSUID README)
Rejected
  • The 64-bit snowflake layout, which needs node-ID assignment and clock discipline, and whose every ID fits in a machine word
  • Sony kept 64 bits but re-cut them: 174 years and 65,536 machines, paid for with a 256-per-10ms mint rate (Sonyflake README)
Flips when
  • Key count is large enough that 8 extra bytes per key, repeated across every index and foreign key, is real money; or when IDs must fit an existing int8 column
  • Flips back the moment you cannot operate worker-ID assignment reliably

Time-ordered keys, or deliberately scrambled ones?

Chosen (B-tree stores)
  • Ordered: random UUIDs mean "all index leaf pages are equally likely to be hit, forcing the whole index into memory" (sequential-uuids README, 2018)
  • RFC 9562 puts the locality gap at "one order of magnitude or more" (RFC 9562 text)
Chosen (range-sharded stores)
  • Scrambled: "Workloads involving sequential keys can have major performance problems on CockroachDB", so hash-sharded indexes convert sequential traffic into uniform traffic (cockroach #78049, 2022)
Flips when
  • The physical placement changes. Appending engines reward order; range-partitioned engines punish it with a single hot range, at a scan-performance cost "proportional to the number of buckets" when you scramble

Show the real ID, or a masked one?

Chosen
  • Mastodon abandoned sequential status IDs specifically so outsiders cannot read table sizes: "My motivation is purely to hide the total number of entries in each table" (PR #4801, 2017)
  • uuidv47 keeps UUIDv7 in the database and emits "a UUIDv4-looking façade at your API boundary" by masking the timestamp with SipHash (uuidv47 README)
Rejected
  • Exposing the bare sequence. The dissent is recorded too: "a huge change for no perceivable benefit to >90% of instances" (nightpool, same PR), and the scope was cut to status IDs only
Flips when
  • IDs never cross a trust boundary (internal services, admin tooling): then masking is pure cost
  • Never flips into using the ID as a secret: "they MUST NOT be used as security capabilities" (RFC 9562)

Figure 3 · The decision, as the sources resolve it

no

yes

yes

no

yes

no

yes

Do writers span nodes,
regions or offline clients?

Store sequence, bigint
from day one

Is the store
range-partitioned by key?

Random or hash-prefixed keys;
hash-shard any ordered index

Can you operate
node-id assignment?

64-bit snowflake layout;
decide the backwards-clock posture

128-bit time-ordered:
UUIDv7 / ULID / KSUID

IDs cross a
trust boundary?

Mask or randomise what you expose;
never treat the ID as a secret

no

yes

yes

no

yes

no

yes

Do writers span nodes,
regions or offline clients?

Store sequence, bigint
from day one

Is the store
range-partitioned by key?

Random or hash-prefixed keys;
hash-shard any ordered index

Can you operate
node-id assignment?

64-bit snowflake layout;
decide the backwards-clock posture

128-bit time-ordered:
UUIDv7 / ULID / KSUID

IDs cross a
trust boundary?

Mask or randomise what you expose;
never treat the ID as a secret

Terminal nodes are actions. The tree encodes the flip conditions from the four blocks above; the trust-boundary question applies to every leaf.
Diagram source
DecisionChosenRejectedBecauseEvidence
Twitter, 2010Node-local time+worker+sequenceStore-issued sequenceThe new store could not issue IDs; 10k IDs/s/process requiredSnowflake README
Rails, 2016bigint default primary keyint defaultExhaustion is a default-setting problem, not an app problemPR #26266
Rails, 2016 (first attempt)Closed unmergedbigserial for Postgres only"Running the same migration on Rails 4 and Rails 5 will produce a different database schema" (tenderlove); superseded by #26266 with a compatibility layerPR #24962
Mastodon, 2017Timestamp IDs, in-database, statuses onlySequential IDs; also rejected: converting every tableHide table sizes; scope cut after maintainer pushbackPR #4801
Sony, 201539/16/8 bit splitSnowflake's 41/10/12Lifetime and machine count over mint rateSonyflake README
Segment, 2017160-bit KSUIDUUIDv4 (unordered), 64-bit flake (coordination)Time-ordering without a worker registryKSUID README
IETF, 2024Standardise time-ordered UUIDv7Leaving RFC 4122 as-is~16 non-standard schemes had already filled the gapRFC 9562 background
PostgreSQL, 2024uuidv7() with 12-bit sub-ms counter (method 3)Refusing on backwards clocksMonotonic within a backend even when the clock steps backcommit 78c5e141
CockroachDB, open questionHash-sharded indexes exist, off by defaultMaking them the default (proposed 2022, still open)Scan cost "proportional to the number of buckets" vs users not discovering the fix#78049
04

What broke in production

Four failure classes cover every identifier incident in this corpus: exhaustion, duplicate issuance, hot placement, and leakage. The first two have public incident records; the second two surface as design changes made under pressure.

Exhaustion deserves its arithmetic stated once, plainly. A signed 32-bit key dies at 2,147,483,647. GitLab opened its tracking issue in April 2020; by April 2021 the ci_builds sequence stood at 1.168 billion, 54.42% of capacity, and the issue forecast overflow "between September 2021 (at a worst case scenario using an exponential forecast) and May 2022 (following a polynomial model)". Twelve to twenty-five months of runway, against a conversion machinery (MR !49778, December 2020) whose long tail was still breaking self-managed upgrades in June 2024. The runway and the remediation were the same order of magnitude, and that is the general lesson: by the time a busy table is at half capacity you are not early, you are on schedule.

Postmortem

The events table stopped at 2,147,483,647

AssumptionAn int primary key on a busy tracking table would outlive the product, or someone would notice in time.
What happenedThe postmortem collection's entry: "In November 2018 a database hit the integer limit, leaving the service in read-only mode" (Basecamp). The same class hit Strava in July 2014 ("hit the signed integer limit on a primary key, causing uploads to fail").
Blast radiusBasecamp: hours of read-only operation for all users. Strava: uploads down until remediated. (Durations per the collection entries; primary write-ups sit on hosts unreachable from this session.)
FixWiden to 64 bits, then fix the default so the next table starts wide, which is what Rails did framework-wide in 2016.
Design ruleAlert on max(id) / type_max per table at 25% and 50%, not on failure. The metric is one query and it never pages you twice.
Postmortem

The foreign key ran out before the primary did

AssumptionWidening the primary key column finishes the job.
What happenedGitHub, May 2021, per the collection entry: "A foreign key on the scoped-tokens table hit max INT32, causing high failure rates for Actions and Pages ... for 9h48m." Nextcloud shows the same shape in slow motion: oc_filecache.fileid is bigint, but an app table (oc_files_antivirus.fileid, int(10) unsigned) lagged, and an operator hit "Numeric value out of range" in production in August 2026.
Blast radiusGitHub: 9h48m of elevated failures on two products. Nextcloud: file processing errors on large instances until the column is altered.
FixGitLab's helpers convert "a primary key and all the foreign keys that reference it" as one programme, which is the correct unit of work.
Design ruleThe exhaustion unit is the ID's whole reference graph, not the column. Audit every column that stores this ID, in your schema and in your plugins'.
Postmortem

A two-byte counter took the webhooks down

AssumptionA failure counter is bookkeeping, too small to be a capacity concern.
What happenedGitLab.com production incident, May 2021: "32768 is out of range for ActiveModel::Type::Integer with limit 2 bytes." The code ran hook.update!(recent_failures: hook.recent_failures + 1) with no bound, and a persistently failing hook walked the smallint to its ceiling.
Blast radiusWebHookWorker errors on GitLab.com, tracked as production incident gl-infra/production#4589.
FixBound the increment; the issue's own words: "we should change this so we don't risk an overflow."
Design ruleEvery monotonically incremented column is an exhaustion clock, not only primary keys. If the value has no natural ceiling, the code must impose one; the type will otherwise impose its own, in production.
Postmortem

Application-allocated IDs collided under concurrency

AssumptionComputing the next per-project issue number (iid) in application code is equivalent to letting the database issue it.
What happenedLong-running imports raced normal issue creation on GitLab.com: two allocators computed the same iid, and inserts failed with "duplicate key value violates unique constraint 'index_issues_on_project_id_and_iid'", surfacing as recurring production exceptions from July 2020.
Blast radiusFailed imports and issue creation for affected projects; recorded via the production error tracker in the issue.
FixRoute every allocation through the one InternalId mechanism, or reserve the whole block up front ("track_greatest" over the import's range).
Design ruleThere can be exactly one allocator per ID space. A second code path that "computes" the next ID is a collision generator waiting for concurrency.

The duplicate-issuance class has a second, quieter member that operators meet after a failover rather than in code review. GitLab's Patroni runbook documents the signature: a storm of "duplicate key value violates unique constraint" errors that "can [be] because of a sequence ... has been modified (i.e. has been RESET)", with the remediation being setval() above the table's true maximum. The runbook states the symptom and the cause; the sequence diagram below reconstructs the path from those two facts, and is marked as our reconstruction, not the runbook's narrative.

Figure 4 · How a failover turns the sequence into a duplicate generator

New primary(promoted)PrimaryAppNew primary(promoted)PrimaryAppPrimary fails.Sequence state on the promoted nodelags or is reset below max(id)Storm continues until setval()is run above the true max(id)INSERT (nextval gives 1000)1INSERT (nextval gives 990)2ERROR duplicate key valueviolates unique constraint3
New primary(promoted)PrimaryAppNew primary(promoted)PrimaryAppPrimary fails.Sequence state on the promoted nodelags or is reset below max(id)Storm continues until setval()is run above the true max(id)INSERT (nextval gives 1000)1INSERT (nextval gives 990)2ERROR duplicate key valueviolates unique constraint3
Reconstruction (inferred): the runbook documents the error and the sequence-reset cause; the intermediate steps are the only mechanism consistent with both. Source: GitLab Patroni runbook.
Diagram source

Two classes remain, and their absence from incident trackers is itself information. Hot placement never appears in this corpus as a postmortem; it appears as engineering effort, twice, in opposite directions: CockroachDB building hash-sharded indexes because an index on a timestamp column "would have a hot spot" (the 2019 design issue that produced the USING HASH syntax), and the Postgres ecosystem building sequential and time-ordered UUIDs because unordered keys touch every leaf page. Our reading: placement failures degrade rather than break, so they get filed as performance work, not incidents, and you will not find them by searching outage reports. Leakage likewise: Mastodon's PR is a privacy fix shipped before any documented harm, RFC 9562 writes the rule ("Implementations SHOULD NOT assume that UUIDs are hard to guess ... they MUST NOT be used as security capabilities"), and uuidv47 exists because UUIDv7's own timestamp is a small leak. No public postmortem in reach of this session attributes a breach to enumerable IDs, and that is an open state, not evidence of safety: the well-known enumeration incidents live in reporting this session cannot fetch, so we name the gap rather than citing from memory.

Figure 5 · GitLab's runway against GitLab's remediation, from its own tracker

2020202020212021202120212022202220222022202320232023202320242024Overflow tracker opened (215017) bigint helpers merged (49778) Conversions across tables Measured 54.42% of capacity Forecast exhaustion window Cleanup still failing upgrades (468671) RunwayRemediation
2020202020212021202120212022202220222022202320232023202320242024Overflow tracker opened (215017) bigint helpers merged (49778) Conversions across tables Measured 54.42% of capacity Forecast exhaustion window Cleanup still failing upgrades (468671) RunwayRemediation
The forecast exhaustion window (from #215017) against the conversion programme's artefacts (!49778, #468671): the fix spans more calendar than the runway did.
Diagram source
05

Numbers you can plan against

Ceilings, rates and lifetimes, each with its source and date. These are spec and repository figures (measured or stated by the implementer), not vendor benchmarks.

MetricValueAtContextAs ofSource
Signed int4 key ceiling2,147,483,647any SQL storethe number every unwidened key dies attimelessGitLab #215017
smallint ceiling32,767GitLab.comreached by an unbounded failure counter in production2021-05#330817
ci_builds capacity consumed54.42%GitLab.com1.168bn of 2.147bn, on the measurement in the tracker2021-04-10#215017
Forecast runway at that point5–13 monthsGitLab.comexhaustion between Sept 2021 (exponential) and May 2022 (polynomial)2021-04#215017
Snowflake mint requirement10,000/s/process, <2 msTwitteruncoordinated across data centres; k-sorted within ~1 s2010README
Snowflake per-tick ceiling4,096/ms/workerTwitter12 sequence bits; 1,024 workers max (10 bits)2010README
Sonyflake per-tick ceiling256/10 ms/instanceSonythe price of 174 years of epoch and 65,536 machines in 64 bits2015README
Mastodon per-tick ceiling65,536/msMastodon2 sequence bytes under a 6-byte ms timestamp, minted inside Postgres2017-10PR #4801
KSUID size and lifetime20 bytes; ~100+ yearsSegment32-bit timestamp from a 2014-05-13 epoch, 128 random bits, 27-char base62 text2017README
ULID timestamp horizonyear 10889ULID spec48-bit millisecond timestamp; 80 random bits; generation fails on same-ms overflow2016spec
UUIDv7 locality claim≥10×RFC 9562"index locality vs random data inserts can be one order of magnitude or more"; a spec claim citing implementer experience, not a benchmark in the RFC itself2024-05RFC text
Postgres uuidv7 sub-ms counter12 bitsPostgreSQLRFC method 3; monotonic within a backend even on backwards clocks2024-12-11commit 78c5e141
Widening cost, offline pathhours to daysNextcloud"can take several hours or even days" for the filecache conversionchecked 2026-09admin manual
Widening cost, online path~3.5+ yearsGitLabhelpers merged 2020-12; conversion cleanup still failing upgrades 2024-06 (calendar span of the programme, derived from the two artefacts' dates)2024-06#468671
Read these carefully

Everything above is a stated spec limit, a repository measurement or a dated forecast; none of it is an independent benchmark. The RFC's "order of magnitude" is the spec summarising implementer experience, and the strongest quantified account of the mechanism (whole-index cache pressure from random keys) is the sequential-uuids README; the benchmark numbers behind it live on a blog host outside this session's reach. The "~3.5+ years" figure is derived by us from the dates of two GitLab artefacts and marks the programme's calendar span, not continuous effort on one table.

06

The evidence wall

Every source behind this page, graded. Built from repository artefacts: this session's network reaches code hosts but not blog, paper or talk hosts, so those tiers are absent by constraint, not by judgement; the ledger in sources.md records the policy.

Postmortem GitLab2021-05

The WebHook recent_failures counter may overflow (#330817)

A production incident on GitLab.com: an unbounded increment walked a smallint to 32,768 and WebHookWorkers started throwing. Links the internal incident (gl-infra/production#4589) and shows the exact offending line.

Carry forwardEvery counter column is an exhaustion clock; bound it in code or the type bounds it for you.
gitlab.com/gitlab-org/gitlab/-/issues/330817
Postmortem GitLab2020-07

Duplicate key on index_issues_on_project_id_and_iid (#229614)

Recurring production exceptions: imports and live issue creation both computed the next per-project iid and collided. The thread walks through routing all allocation through one InternalId mechanism or reserving blocks up front.

Carry forwardOne allocator per ID space; a second "compute the next one" code path is a latent collision.
gitlab.com/gitlab-org/gitlab/-/issues/229614
Postmortem Nextcloud ecosystem2026-08

fileid numeric value out of range (files_antivirus #697)

An operator's production report: oc_filecache.fileid outgrew an app table's int(10) unsigned copy of it, and file processing failed with SQLSTATE 22003. The core widened years earlier; the plugin's column lagged.

Carry forwardExhaustion propagates along foreign keys and plugin schemas; audit the ID's whole reference graph.
github.com/nextcloud/files_antivirus/issues/697
Postmortem Collection (Basecamp, GitHub, Strava)2014–2021

danluu/post-mortems: the exhaustion entries

The curated postmortem index records three ID-exhaustion outages: Basecamp November 2018 ("a database hit the integer limit, leaving the service in read-only mode"), GitHub May 2021 (scoped-tokens foreign key at max INT32, "9h48m"), Strava July 2014 (signed-int primary key, uploads failed). The primary write-ups sit on hosts this session's network cannot reach; claims here are the collection's entries.

Carry forwardExhaustion is a recurring, named, multi-company outage class, not a theoretical risk.
raw.githubusercontent.com/danluu/post-mortems/master/README.md
Decision record GitLab2020-04

Tracking primary key integer overflow risk for ci_builds.id (#215017)

The near-miss, recorded in public: the largest CI table measured at 1.168bn IDs (54.42% of int4) in April 2021, with exhaustion forecast between September 2021 and May 2022 depending on the growth model.

Carry forwardForecast exhaustion per table with two growth models and plan against the pessimistic one.
gitlab.com/gitlab-org/gitlab/-/issues/215017
Source GitLab2020-12

Migration helpers for int to bigint conversion (!49778)

The online widening machinery: shadow bigint column, trigger keeping both in sync, batched background backfill, then the swap; explicitly "DOWNTIME = false", covering the primary key and every referencing foreign key.

Carry forwardOnline widening is a dual-write system on your own primary key; budget it as a programme, not a migration.
gitlab.com/gitlab-org/gitlab/-/merge_requests/49778
Source GitLab2024-06

Cleanup migration failure for p_ci_builds bigint conversion (#468671)

The long tail: a cleanup migration from the bigint programme raising PG::DependentObjectsStillExist in an upgrade test environment, three and a half years after the helpers merged.

Carry forwardThe conversion is not done when the column swaps; the cleanup phase can still break upgrades years later.
gitlab.com/gitlab-org/gitlab/-/work_items/468671
Source GitLabliving doc

Patroni runbook: duplicate key log analysis

The operator's view of duplicate issuance: unique-violation storms explained as a sequence that "has been modified (i.e. has been RESET)", remediated with setval() above the true maximum or absorbed with ON CONFLICT.

Carry forwardAfter any failover, verify sequences against max(id) before declaring the database healthy.
gitlab.com/gitlab-com/runbooks: patroni/log_analysis.md
Source Nextcloud2019-01

Columns missing conversion to big int (server #13704)

Why widening ships as an opt-in command: "changing column types on big tables could take some time", so upgrades skip it and admins run occ db:convert-filecache-bigint offline.

Carry forwardIf you ship software others operate, the widening you defer becomes thousands of other people's maintenance windows.
github.com/nextcloud/server/issues/13704
Vendor Nextcloudchecked 2026-09

Admin manual: bigint identifiers

The official cost statement: conversion "can take several hours or even days, depending on the number of files", with the web server down or maintenance mode on.

Carry forward"Hours or days, offline" is the price of the narrow default, stated by the people who charge it.
nextcloud/documentation: bigint_identifiers.rst
Source Twitter2010

Snowflake README (snowflake-2010 branch)

The founding document of the node-local family: 10k IDs/s/process under 2 ms, uncoordinated, k-sorted within about a second, 64 bits; and the clock posture, refusing to mint until time passes the last issued ID.

Carry forwardDecide the backwards-clock behaviour (refuse vs counter) before the first deploy; it is the generator's availability contract.
github.com/twitter-archive/snowflake
Source Sony2015

Sonyflake README

The same 64 bits, re-budgeted: 39 bits of 10 ms ticks (174 years), 16 bits of machine (65,536 nodes), 8 bits of sequence (256 per 10 ms). A worked example of the bit-budget trade.

Carry forwardLifetime, node count and mint rate trade against each other inside 64 bits; write your numbers down before picking a layout.
github.com/sony/sonyflake
Source Segment (Twilio)2017

KSUID README

Time-ordered without coordination: 32-bit timestamp plus 128 random bits, "64 times larger than the 122 bits" of UUIDv4; 20 bytes binary, 27 characters of base62 that sort correctly as text.

Carry forwardBuying out of worker-ID assignment costs about 12 extra bytes per key, everywhere the key appears.
github.com/segmentio/ksuid
Source ULID project2016

ulid/spec

The spec that names UUIDv4's problem ("can cause fragmentation in many data structures") and defines same-millisecond monotonicity by incrementing the random component, with generation failing on overflow.

Carry forwardRead the monotonicity clause of any ID spec for its overflow behaviour; that is where the guarantees end.
github.com/ulid/spec
Decision record Mastodon2017-10

Non-serial ("snowflake") IDs (PR #4801)

A complete recorded argument: motivation ("purely to hide the total number of entries in each table"), dissent ("no perceivable benefit to >90% of instances"), scope cut to statuses, IDs returned as strings to protect JavaScript's 53-bit integer precision.

Carry forwardSequential public IDs publish your growth curve; and any 64-bit ID that reaches a browser must travel as a string.
github.com/mastodon/mastodon/pull/4801
Decision record IETF uuidrev WG2024-05

rfc4122bis working repository

The UUID revision drafted in public on GitHub and published as RFC 9562 in May 2024, obsoleting RFC 4122 after 19 years.

Carry forwardThe standard now includes what production converged on; new systems no longer need a bespoke scheme to get sortable IDs.
github.com/ietf-wg-uuidrev/rfc4122bis
Decision record IETF uuidrev WG2024

RFC 9562 text: locality, monotonicity, security, background

Four load-bearing statements: the order-of-magnitude locality claim for time-ordered keys; three standardised monotonicity methods; "MUST NOT be used as security capabilities"; and a background cataloguing ~16 non-standard schemes the industry built first.

Carry forwardThe RFC is the decision record for the whole field: read its background section as a map of what everyone tried.
rfc4122bis: draft-ietf-uuidrev-rfc4122bis.md
Source PostgreSQL2024-12

commit 78c5e141: Add UUID version 7 generation function

uuidv7() lands with RFC method 3: a 12-bit sub-millisecond fraction in rand_a, keeping generation monotonic within a backend "even when the system clock goes backward."

Carry forwardA database-resident generator can absorb clock steps a node-resident one must refuse; that is a real availability difference.
github.com/postgres/postgres/commit/78c5e141...
Source 2ndQuadrant / Tomas Vondra2018

tvondra/sequential-uuids

The clearest statement of the random-key problem: uniform distribution means "all index leaf pages are equally likely to be hit, forcing the whole index into memory"; plus two wrap-around generator designs that bound the damage without going fully sequential.

Carry forwardThe pain threshold is the index outgrowing shared buffers; below it random keys are fine, above it they thrash.
github.com/tvondra/sequential-uuids
Source Rails2016-12

PR #26266: Change default primary keys to BIGINT

The systemic fix, merged: new Rails tables get 64-bit keys, with a migration compatibility layer so old migrations keep producing int and old schemas stay loadable.

Carry forwardExhaustion is best fixed in the defaults, one framework level above the application that will forget.
github.com/rails/rails/pull/26266
Source Rails2016 (closed unmerged)

PR #24962: bigserial by default (the rejected first attempt)

Closed without merge after core maintainers showed the same migration would produce different schemas on Rails 4 and 5 and schema dumps would drift; superseded by #26266, which added the versioned compatibility layer.

Carry forwardChanging an ID default is an ecosystem migration; the mechanism that versions old behaviour is most of the work.
github.com/rails/rails/pull/24962
Decision record CockroachDB2022-03

Issue #78049: use hash-sharded indexes by default

The range-sharded world's view of ordered keys, argued by a founder: sequential keys cause "major performance problems", the fix exists but is not the default, and the cost of the fix is scan performance "proportional to the number of buckets". Still open.

Carry forwardOn range-partitioned stores, treat any monotonic key (including UUIDv7) as a write hotspot until proven otherwise.
github.com/cockroachdb/cockroach/issues/78049
Source CockroachDB2019-08

Issue #39340: improve UX for hash-sharded indexes

The design argument that produced the USING HASH syntax: an index on a timestamp column "would have a hot spot", the manual workaround (computed hash column plus check constraint) "is starting to get heavy", so the database should "shoulder the burden of hashing" behind new syntax.

Carry forwardIf avoiding ordered-key hotspots takes manual schema gymnastics, teams will not do it; the store has to make the safe shape cheap.
github.com/cockroachdb/cockroach/issues/39340
Source stateless-me2025

uuidv47

Both halves of the tension at once: "store sortable UUIDv7 in your database while emitting a UUIDv4-looking façade at your API boundary", by XOR-masking only the timestamp field with a keyed SipHash-2-4 stream.

Carry forwardIndex order and external opacity are separable; you can keep the fast key inside and show strangers noise.
github.com/stateless-me/uuidv47
07

Build a miniature, then productionise it

Six rungs from an afternoon's query to the drill that would have caught every incident in section 4.

Date your own exhaustion

One query over your schema catalogue: for every int2/int4 key or counter, compute current max over type max and, from 30 days of growth, the projected exhaustion date. This is GitLab #215017 as a report you run monthly.

Done when: every table has a percentage and a date, and the worst one is on a dashboard.  Teaches: exhaustion is a forecastable capacity problem, not a surprise.

Write a snowflake and break its clock

Implement the 41/10/12 layout in ~50 lines. Then step the clock backwards under load and watch what your implementation does: duplicate, block, or refuse like Twitter's. Add the refusal, then add Postgres-style counter absorption, and measure the availability difference.

Done when: a 5-second clock step produces zero duplicates and you can state the write-outage cost.  Teaches: the timestamp field is a distributed-systems dependency, not a convenience.

Reproduce the locality cliff

One table, one unique index, three key generators (bigserial, UUIDv4, UUIDv7), three sizes: index fits in shared buffers, fits in RAM, exceeds RAM. Chart insert throughput and WAL bytes per row.

Done when: your chart shows where random keys fall off, and by how much on your hardware.  Teaches: why the RFC claims an order of magnitude, and why you saw nothing at small scale.

Widen a hot column without stopping it

Load 100M rows with an int4 key, then convert to bigint using GitLab's recipe: shadow column, sync trigger, batched backfill, swap. Keep a write load running the whole time and measure its p99.

Done when: the swap completes with writes uninterrupted and you have a per-100M-rows duration to extrapolate from.  Teaches: what "the fix takes years at scale" is made of.

Audit what your IDs disclose

Take every identifier your API exposes and answer three questions: can a stranger infer volume (sequential), infer creation time (timestamped), or walk the keyspace (dense)? Then put a masked or random public identifier in front of any answer you did not like, uuidv47-style.

Done when: the answers are written down per endpoint, and no authorisation decision anywhere rests on an ID being unguessable.  Teaches: the ID is part of your public API's information surface.

Fail over and check the sequences

In a replicated Postgres pair, kill the primary mid-load, promote, and immediately compare every sequence against its table's max(id). Automate the comparison as a post-failover check, then make the drill routine.

Done when: the check runs unprompted after promotion and a rigged lagging sequence is caught before the first duplicate-key error.  Teaches: uniqueness is state, and state has to survive failover like everything else.

08

Keep hunting

The queries that found this material, copyable. The productive move was searching error strings and tracker vocabulary rather than concept names.

Exhaustion and widening

  • site:gitlab.com gl-infra production "integer overflow" OR "out of range"
  • "out of range for type integer" production issue
  • "convert-filecache-bigint" OR "bigint conversion" issue
  • repo:rails/rails is:pr bigint primary key default

Duplicate issuance

  • "duplicate key value violates unique constraint" sequence RESET runbook
  • site:gitlab.com "PG::UniqueViolation" iid import race
  • snowflake id "clock" "went backwards" refuse generate

Placement and locality

  • "hash-sharded indexes" sequential keys hotspot default
  • random UUID "leaf pages" "shared buffers" index locality
  • uuidv7 "index locality" "order of magnitude" rfc 9562

Design arguments in the open

  • repo:mastodon/mastodon is:pr snowflake non-serial ids
  • ietf-wg-uuidrev rfc4122bis monotonicity counter privacy
  • is:pr is:closed is:unmerged primary key uuid default
  • ksuid OR ulid OR sonyflake README tradeoff epoch bits
09

References

  1. GitLab, Tracking primary key integer overflow risk for ci_builds.id (#215017) GitLab.org tracker, opened 2020-04-20. Checked 2026-09-27.
  2. GitLab, Add migration helpers for converting int columns to bigint (!49778) GitLab.org, merged 2020-12-11. Checked 2026-09-27.
  3. GitLab, cleanup_bigint_conversions_for_p_ci_builds migration failure (#468671) GitLab.org tracker, 2024-06. Checked 2026-09-27.
  4. GitLab, The WebHook recent_failures counter may overflow (#330817) GitLab.org tracker, opened 2021-05-12. Checked 2026-09-27.
  5. GitLab, PG::UniqueViolation on index_issues_on_project_id_and_iid (#229614) GitLab.org tracker, opened 2020-07-16. Checked 2026-09-27.
  6. GitLab, Runbooks: Patroni log analysis GitLab.com infrastructure runbooks, living document. Checked 2026-09-27.
  7. Nextcloud, Some columns in the database are missing a conversion to big int (#13704) GitHub, opened 2019-01-20. Checked 2026-09-27.
  8. Nextcloud, Admin manual: bigint identifiers nextcloud/documentation, living document. Checked 2026-09-27.
  9. Nextcloud ecosystem, fileid numeric value out of range (files_antivirus #697) GitHub, 2026-08-26. Checked 2026-09-27.
  10. Dan Luu (ed.), post-mortems collection: Basecamp 2018, GitHub 2021, Strava 2014 entries GitHub, entries dated 2014–2021. Checked 2026-09-27.
  11. Twitter, Snowflake README (snowflake-2010 branch) GitHub (twitter-archive), 2010. Checked 2026-09-27.
  12. Sony, Sonyflake README GitHub, first released 2015. Checked 2026-09-27.
  13. Segment (Twilio), KSUID README GitHub, 2017. Checked 2026-09-27.
  14. ULID project, specification GitHub, 2016. Checked 2026-09-27.
  15. aschmitz, Non-serial ("snowflake") IDs, Mastodon PR #4801 GitHub, merged 2017-10-04. Checked 2026-09-27.
  16. IETF uuidrev WG, rfc4122bis working repository GitHub; published as RFC 9562, May 2024. Checked 2026-09-27.
  17. IETF uuidrev WG, draft-ietf-uuidrev-rfc4122bis (full text) GitHub raw, 2024. Checked 2026-09-27.
  18. PostgreSQL, commit 78c5e141: Add UUID version 7 generation function GitHub mirror, committed 2024-12-11. Checked 2026-09-27.
  19. Tomas Vondra, sequential-uuids extension GitHub, 2018. Checked 2026-09-27.
  20. Jon McCartie, Change default primary keys to BIGINT, Rails PR #26266 GitHub, merged December 2016. Checked 2026-09-27.
  21. Pavel Pravosud, Make pg adapter use bigserial for pk by default, Rails PR #24962 GitHub, closed unmerged 2016-12-08. Checked 2026-09-27.
  22. Ben Darnell, Use hash-sharded indexes by default, CockroachDB #78049 GitHub, opened 2022-03-17, open. Checked 2026-09-27.
  23. ajwerner, Improve user experience for hash-sharded indexes, CockroachDB #39340 GitHub, opened 2019-08-05, closed. Checked 2026-09-27.
  24. stateless-me, uuidv47 GitHub, 2025. Checked 2026-09-27.