1. What "identity" means in software
Every entity in a software system needs to be distinguishable from every other. An order, a user, a financial transaction. The way that distinction is made directly affects the system's ability to scale, maintain data consistency and evolve over the years. When we talk about "identity" in the context of databases and system architecture, we are talking about the mechanism by which we guarantee that each record can be located, referenced and differentiated from all others without ambiguity.
There is an important difference between logical identity and technical identity. Logical identity is what makes sense to the business domain. A person's Social Security Number, an order number, a shipment tracking code. Technical identity is what the system uses internally to organize and access data. The auto-incremented integer in the id column of a PostgreSQL table, for example, is technical identity. It carries no business meaning, but it is how the database locates that record efficiently.
In most projects, the choice of identifier is made in the first few days and never revisited. A SERIAL in PostgreSQL or an AUTO_INCREMENT in MySQL solves the problem for months or years. The complexity surfaces when the system needs to grow, when data needs to be distributed across multiple databases, when APIs expose those identifiers to the outside world. At that point, a decision that seemed trivial becomes an architectural constraint that is hard to reverse.
"We've been through the painful process of growing the number of bits used to store tweet ids before. It's unsurprisingly hard to do when you have over 100,000 different codebases involved."
β Twitter Engineering, "Announcing Snowflake" (2010)
The Twitter experience illustrates this well. Changing the format of an identifier that already permeates hundreds of thousands of components is an extremely high-risk and costly operation. Understanding the options, the trade-offs and the implications of each identification strategy is therefore an architectural competency that deserves attention proportional to the impact it carries.
This article traces the history and evolution of identifier generation strategies, starting from the simplest scenario (a relational database with sequential integers) and advancing to the challenges of distributed systems handling millions of writes per second. Along the way, each concept builds on the previous one so that complexity grows alongside understanding.
2. The simple world: monoliths and auto-increment integers
How relational databases generate sequential IDs
The oldest and most widespread way to generate identifiers in relational databases is to delegate to the database itself the responsibility of assigning a number to each new row inserted. In MySQL this is done with the AUTO_INCREMENT property in the column definition. In PostgreSQL, the same functionality is offered by the SERIAL and BIGSERIAL types, which internally create a sequence (a database object that maintains an atomic counter).
-- PostgreSQL
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
-- MySQL
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);When a new row is inserted, the database increments the counter and assigns the next available value. This process is atomic, meaning that even with multiple concurrent transactions inserting data into the same table, each one receives a distinct value. The database guarantees uniqueness within that table without the application needing to worry about it.
The guarantees the database provides
This approach offers three guarantees. The database prevents two records from sharing the same primary key value, because the counter only moves forward. Obtaining the next value and inserting the record happen within the same transaction, guaranteeing atomicity. And because values are strictly increasing, the order of IDs reflects (approximately) the order in which records were created.
That third property is particularly useful for debugging and day-to-day operations. If you know that order ID 5,000 was created before order ID 5,001, you can reason about the sequence of events without consulting additional timestamps. In quick analyses, ad-hoc queries and log reading, that natural ordering saves time.
Why indexing works so well
To understand why sequential IDs perform better in databases, it is necessary to understand how databases physically organize data on disk. Relational databases do not write records individually, one by one. They group records into units called pages (or blocks), with a fixed size: typically 8 KB in PostgreSQL and 16 KB in MySQL. When the database needs to read or write a record, it operates on the entire page that contains it. What determines performance is not the number of records accessed but the number of pages that need to be read or written to disk.
The most widely used relational databases (PostgreSQL, MySQL with InnoDB) organize the primary key index in structures called B-trees (or variants like B+trees). A B-tree is a balanced tree composed of pages: the leaves (leaf pages) contain the actual data or pointers to it, and internal nodes contain keys that guide navigation to the correct leaf. When the keys inserted are sequential, new entries always go to the rightmost end of the tree, modifying only the last data page, called the rightmost leaf page. Previous pages remain untouched.
This insertion pattern is called append-only and has two positive effects. The database keeps the rightmost leaf page in the buffer cache (the memory region where disk pages live while they are "hot"), because it is always that page receiving new insertions. The cache hit rate for writes approaches 100%. Additionally, page splits do not occur.
A page split happens when a page is completely full and a new key arrives that should be inserted into it. The database must split the page in two, move half the keys to the new page and update the pointers in the B-tree's parent nodes to reflect the new structure. The entire operation is recorded in the WAL. The immediate result is that both pages drop to an average of 50% occupancy, wasting space and reducing buffer cache efficiency. With sequential insertions this problem does not occur: the rightmost leaf page is only split when completely full, and the new page immediately starts receiving all subsequent insertions, quickly reaching 100% occupancy again. With random insertions, such as UUID v4, each new key goes to an unpredictable position in the tree, forcing page splits distributed throughout the index and progressively fragmenting it.
The PostgreSQL documentation describes this behavior in the context of B-tree indexes, noting that sequential inserts are the most favorable case for this data structure (PostgreSQL Documentation, "B-Tree Indexes"). MySQL InnoDB shows similar behavior, since the primary key defines the physical order of data on disk (clustered index), and sequential insertions avoid page reorganization.
Operational simplicity
Beyond performance, there is an operational gain that is hard to quantify but easy to notice. When someone reports a problem with "order 47832", finding it is trivial. A SELECT * FROM orders WHERE id = 47832 resolves it. When two systems need to communicate about a record, exchanging a number is enough. When a support team needs to investigate an incident, they can sort events by the id column and follow the chronological sequence.
This simplicity should not be underestimated. Systems that adopt more complex identifiers before needing them pay a daily cognitive cost in every debugging operation, every exploratory query, every conversation between teams. Complexity has its place, but should be adopted when there is a genuine need for it.
3. First fractures: when the monolith starts to scale
The single-writer limit
The monolith architecture with a single database works well up to a point. When read volume grows, the most common strategy is to add read replicas. These replicas receive an asynchronous copy of the data and respond to read queries, relieving the load on the primary database. In this scenario, ID generation remains centralized in the primary, and the replicas simply reflect the IDs it generated.
An INSERT with auto-increment follows a specific path inside the database before any data reaches disk. When a transaction inserts a record with auto-increment, the first thing that happens is the acquisition of an exclusive lock on the sequence counter. That lock guarantees that no other transaction obtains the same value. With the next ID in hand, the database records the operation in the WAL (Write-Ahead Log), a sequential file on disk that serves as a journal. Only then is the change applied to the pages of the buffer cache, the shared memory where database pages live while they are "hot". The actual disk write is asynchronous: a background process (the bgwriter in PostgreSQL, the InnoDB buffer pool flusher in MySQL) periodically drains modified pages to persistent storage. What looks simple on paper involves thread synchronization, mandatory sequential writes to disk (the WAL) and contention in shared memory.
The problem emerges when write volume grows. In a conventional relational database, all writes pass through a single instance (the "primary" or "master"). That instance has hardware limits, disk throughput limits, processing capacity limits. When the volume of inserts reaches tens of thousands per second, contention on the auto-increment lock can become measurable. Each transaction must acquire that lock, increment the counter and release it before the next transaction can proceed. At moderate volumes this cycle is imperceptible. At high volumes, transactions start queuing up waiting for the lock, and what was microseconds becomes milliseconds accumulating in the p99 latency.
Sharding and the breakdown of sequentiality
The first strategy for scaling writes is sharding (horizontal partitioning), where data is distributed across multiple databases. Each shard contains a subset of the data, and the application decides which shard to direct each operation to based on some partitioning key (for example, user_id modulo the number of shards).
At this point sequential IDs start to fail. If two independent shards generate IDs using their own auto-incremental counters, both will produce IDs 1, 2, 3, 4, and so on. There will be collisions (two distinct records with the same ID, which corrupts referential integrity and can overwrite data). One solution is to configure each shard with a different offset and increment. For example, with two shards, the first generates odd IDs (1, 3, 5, 7...) and the second generates even IDs (2, 4, 6, 8...). This approach works, but loses the temporal ordering property (ID 3 from shard 1 may have been created after ID 4 from shard 2) and makes adding new shards difficult (reconfiguring the offsets is a delicate operation).
"The typical solution that works for a single database β just using a database's natural auto-incrementing primary key feature β no longer works when data is being inserted into many databases at the same time."
β Instagram Engineering, "Sharding & IDs at Instagram" (2012)
Instagram faced exactly this situation. With more than 25 photos and 90 likes per second being processed, the team needed to fragment data across multiple PostgreSQL servers. Native auto-increment was no longer viable as an ID generation strategy.
Ticket Servers: the first elegant workaround
An intermediate approach, created before the era of massive distributed systems, was the concept of Ticket Server, popularized by Flickr in 2006. The idea is to have one (or two, for high availability) databases dedicated exclusively to generating IDs. These databases do not store business data; they only maintain counters.
-- Flickr Ticket Server schema
CREATE TABLE Tickets64 (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
stub CHAR(1) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY stub (stub)
) ENGINE=MyISAM;
-- To get a new ID:
REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();Flickr operated two Ticket Servers with interleaved offsets to avoid a single point of failure. The Flickr engineering team described this solution as an example of the design principle they adopted internally: use the simplest solution that solves the problem. The ticket servers went into production on Friday, January 13, 2006, and served more requests than ever with the machines practically idle on every metric (Flickr Engineering, "Ticket Servers: Distributed Unique Primary Keys on the Cheap", 2010).
The limitation is that a centralized component still exists. If the Ticket Servers become unavailable, no service can generate new IDs. For systems that need fully decentralized ID generation, something different was needed.
The information leak
There is a security problem that is often noticed too late. Sequential IDs leak information. If your system exposes IDs in URLs (like /api/users/1234), an attacker can infer how many records exist in the system, what the growth rate is and, most importantly, can enumerate all records by simply incrementing the number.
This class of vulnerability is known as IDOR (Insecure Direct Object Reference) and has been listed in the OWASP Top 10 since 2007. OWASP describes the vulnerability as arising when applications use user-supplied input to access objects directly, without adequate authorization checks (OWASP, "Insecure Direct Object Reference Prevention Cheat Sheet").
A documented and widely discussed example involves the social network Parler. The platform used sequential identifiers for posts, which allowed mass extraction of terabytes of public data by simply iterating over the IDs (Wikipedia, "Insecure direct object reference").
Sequential IDs exposed in APIs or URLs are an attack vector that requires rigorous access controls regardless of any other measure. And the choice of identifier, as the Parler case illustrates, has security implications that extend beyond the data layer.
4. The jump to distributed systems
Why independent services cannot depend on a central generator
When a monolithic application is decomposed into microservices, each service gains its own database (the "database per service" pattern). In this model, there is no longer a single database that generates all IDs. Each service needs to be able to generate identifiers independently, without coordination with the others.
This need arises from an architectural principle of microservices: loose coupling. If all services depend on a central ID generator, that component becomes a single point of failure and a performance bottleneck. Every insertion in any service would require a network call to the generator, adding latency and introducing a shared failure mode.
The CAP Theorem and eventual consistency
In distributed systems, the CAP Theorem (formulated by Eric Brewer in 2000 and formally proven by Gilbert and Lynch in 2002) establishes that a distributed system can simultaneously offer at most two of the following three properties: Consistency (all nodes see the same data at the same time), Availability (every request receives a response) and Partition Tolerance (the system continues operating even with network failures between nodes).
In practice, network partitions are inevitable, so the choice falls between favoring consistency or availability. Most modern distributed systems opt for eventual consistency, where data converges to a consistent state over time but may show temporary divergences.
This choice directly impacts ID generation. In a system with eventual consistency, two nodes can generate IDs "at the same time" without knowing about each other's existence. If both generate the same ID, there will be a conflict that can corrupt data. The ID generation mechanism must therefore guarantee uniqueness even without coordination, which is a strong constraint that eliminates the viability of centralized counters in high-availability scenarios.
What we need from a distributed identifier
A distributed identifier must meet a set of requirements that go beyond what a simple auto-increment offers.
It must be globally unique without depending on coordination between nodes. It must be generated locally within each service, without network calls. Ideally, it must be sortable by time, to preserve the ability to reason about the sequence of events. And it must work well with database indexing structures, so as not to degrade read and write performance.
These are the constraints that motivated the development of identifier formats like UUID, ULID, KSUID and Snowflake, each making different trade-offs to address distinct scenarios.
The axis that organizes all formats: where does coordination happen?
There is a question that organizes all formats: where is the cost of ensuring that two records never share the same identifier placed?
In BIGINT auto-increment, that cost sits in the database lock. Each transaction that needs an ID must acquire the counter, increment it and release it. The database serializes every insertion that requires a new identifier, and the price is contention under high write load. Coordination happens on every operation.
The Flickr Ticket Server shifts coordination out of the application database but keeps it centralized. A dedicated service owns the counter. The cost migrates from contention in the application database to a network call to a separate service. The single point of failure persists; it just moved.
UUID v4 eliminates runtime coordination by transferring uniqueness to the probabilistic space. No component needs to communicate with any other. Each generator produces 122 random bits and trusts that the probability of collision is negligible in any production scenario. The price is the absence of ordering: random generation means no temporal correlation between the identifier and the moment of creation.
Snowflake splits the problem into two stages. Machine IDs require coordination once (assigning each worker a distinct number, typically via Zookeeper or manual configuration), but ID generation itself requires no further coordination after that. Within each machine, a local counter increments every millisecond. The cost of coordination is paid at deploy time, not at request time.
UUID v7, ULID and KSUID occupy a different position: they use time as a coarse coordinator and randomness as a fine disambiguator. No component communicates with any other at generation time. The timestamp in the most significant bits provides monotonic ordering across different machines (approximately, within the precision of clock synchronization), and the random bits in the least significant bits handle collision when two identifiers are generated in the same millisecond on different machines. The residual risk is clock drift: if a machine's clock moves backward, the ordering guarantee breaks.
This question (where does coordination happen?) is a lens that classifies any identifier format, including ones that will be created after this article. It also predicts the trade-offs before examining the implementation: the further from runtime coordination, the greater the distribution capacity, but the greater the dependence on probabilistic guarantees or the reliability of physical clocks.
5. UUID: the first universal answer
The concept and history
UUID (Universally Unique Identifier) is a 128-bit identifier format originally standardized by RFC 4122 in 2005, with roots in earlier work by Apollo Computer and DCE (Distributed Computing Environment) in the 1980s. The idea is to generate identifiers that are globally unique without the need for a central registry or coordination between the parties generating them.
A UUID is 128 bits long and is represented as a 36-character hexadecimal string in the 8-4-4-4-12 format:
550e8400-e29b-41d4-a716-446655440000RFC 9562 defines the UUID layout with the notation xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx, where M identifies the version and N identifies the variant. In the example above, the character 4 in the M position indicates it is a UUID v4, and the a in the N position (in hexadecimal, a = 1010 in binary, whose two most significant bits are 10) confirms the RFC 4122 variant. Different versions use different strategies to guarantee uniqueness.
The versions that matter
Not all UUID versions are equivalent. The three most relevant for the discussion of modern systems are v1, v4 and v7.
UUID v1 combines a high-resolution timestamp (100-nanosecond intervals since October 15, 1582) with the MAC address of the machine that generated the identifier. This version guarantees uniqueness deterministically, but exposes infrastructure information (the MAC address) and, due to a quirk in the bit layout, does not sort lexicographically by time. RFC 9562, published in May 2024, addresses these privacy and layout issues in newer versions.
UUID v4 is the most widely used version. It consists of 122 random bits (the remaining 6 are used to indicate version and variant). The probability of collision is extremely low. To have a 50% chance of a collision, you would need to generate approximately 2.71 Γ 10^18 UUIDs (computationally infeasible in any production scenario). This property makes UUID v4 the standard choice for decentralized ID generation, where each node can generate UUIDs independently with confidence that there will be no duplicates.
UUID v7, standardized by RFC 9562 in May 2024, is the most recent and most relevant evolution for use as a primary key in databases. It incorporates a 48-bit Unix timestamp (millisecond precision) in the most significant bits, followed by random bits. This makes UUID v7 monotonically increasing over time and lexicographically sortable. It is the version that solves the biggest problem with UUID v4 as a primary key, which we will discuss next.
The performance problem nobody told you about
UUID v4 is excellent for guaranteeing uniqueness without coordination. But when used as a primary key in a relational database, it creates a performance problem that only manifests as the table grows.
The problem is the randomness. Each new UUID v4 is completely random, meaning each new insertion goes to a random position in the index's B-tree. Instead of always inserting at the end (as happens with sequential integers), the database must locate the correct page somewhere in the tree, potentially doing disk I/O to bring in a page not currently in cache.
When the destination page is full, a page split occurs: the database divides the page in two and redistributes the keys. Page splits are expensive operations in terms of I/O and generate additional records in the WAL (Write-Ahead Log), the mechanism the database uses to guarantee durability. An article on Better Stack about UUID v7 in PostgreSQL 18 notes that indexes with random insertions tend to average 69% page capacity (instead of close to 100% with sequential insertions), wasting disk space and reducing cache efficiency (Better Stack, "UUID v7 in PostgreSQL 18").
The impact is measurable. Buildkite, a CI/CD platform, documented their migration from random UUIDs to time-ordered UUIDs in a 2023 blog post. Along with other optimizations carried out over a six-week period, the company observed a 50% reduction in the WAL generation rate of the primary database (Buildkite Engineering, "Goodbye to sequential integers, hello UUIDv7!", 2023).
"With this change (along with several other changes over a 6 week period), we observed a 50% reduction in the WAL (Write Ahead Log) rate of the primary database."
β Buildkite Engineering (2023)
The diagram below conceptually illustrates the difference between sequential and random insertions in a B-tree:
graph LR
subgraph "Sequential Insertion (INT / UUID v7)"
A1[Page 1
IDs 1-100
π’ Full] --> A2[Page 2
IDs 101-200
π’ Full] --> A3[Page 3
IDs 201-250
π‘ Inserting here]
end
subgraph "Random Insertion (UUID v4)"
B1[Page 1
π΄ Split!] --> B2[Page 2
π΄ Split!]
B3[Page 3
π‘ 69% usage] --> B4[Page 4
π‘ 69% usage]
B5[Page 5
π΄ Split!] --> B6[Page 6
π‘ 69% usage]
endThe effect accumulates. In small tables (fewer than 100,000 records), the entire index fits in the buffer cache and the difference is imperceptible. As the table grows and the index no longer fits in memory, each random insertion has a high probability of causing a disk I/O, and the degradation becomes exponential.
The storage and readability cost
Beyond indexing performance, UUID v4 has other practical disadvantages. At 128 bits (16 bytes in binary format, or 36 characters in text representation), it takes up twice the space of a 64-bit BIGINT. That cost is multiplied by every index referencing the column, every foreign key in related tables, every WAL entry. For concrete scale: a table with 100 million records, two secondary indexes and three tables with foreign keys pointing to it accumulates around 800 MB in references with BIGINT. The same schema with UUID v4 reaches 1.6 GB. The difference doubles if foreign keys are stored as text (36 characters) instead of BINARY(16). This storage excess pressures the buffer cache, increases the volume of data transferred between disk and memory and reduces cache effectiveness for data pages.
Readability also suffers. Comparing 550e8400-e29b-41d4-a716-446655440000 with 42 during a 3 AM debugging session is an experience any engineer who has been through it can describe. UUIDs are hard to memorize, to communicate verbally and to type in manual queries.
6. The evolution: time-sortable IDs
The recognition that the randomness of UUID v4 was harmful to database performance motivated the development of alternative formats that combine global uniqueness with temporal ordering. Three stand out: ULID, KSUID and UUID v7.
ULID: Universally Unique Lexicographically Sortable Identifier
ULID was proposed as an open specification in 2016 (available at github.com/ulid/spec). It is 128 bits long, the same as a UUID, and is composed of two components: 48 bits of timestamp (milliseconds since the Unix epoch) followed by 80 bits of randomness.
01ARZ3NDEKTSV4RRFFQ69G5FAV
|-------||----------------|
Timestamp Randomness
48 bits 80 bitsThe text representation uses Crockford's Base32, resulting in 26 characters (versus 36 for UUID). This encoding is case-insensitive, contains no special characters (URL-safe) and sorts lexicographically in the same way as the binary representation. ULIDs generated at different moments are naturally ordered by time, and ULIDs generated in the same millisecond are differentiated by the random part.
Binary compatibility with UUID is a practical advantage. Since both have 128 bits, a ULID can be stored in a UUID-type column in PostgreSQL without schema modification. This property facilitates incremental migrations.
KSUID: K-Sortable Unique Identifier
KSUID was developed by the Segment team (now Twilio Segment) when they faced the need to sort identifiers by time to enable log archiving on Amazon S3. Segment's blog post describes that the team started using UUID v4, and after a few weeks the need to order those identifiers temporally arose. The initial motivation was to group messages by time ranges for archiving, and random UUIDs would result in random dispersion with no natural grouping property (Segment Engineering, "A Brief History of the UUID", 2017).
"After a few weeks, a requirement to order these identifiers by time emerged. [...] The existing UUIDs would have resulted in random dispersion of messages with no natural grouping property."
β Segment Engineering (2017)
KSUID differs from ULID in a few ways. It has 160 bits (20 bytes): 32 bits of timestamp with 1-second resolution and 128 bits of random payload. The text representation uses Base62, resulting in 27 alphanumeric characters. The custom epoch (May 13, 2014) allows more than 100 years of useful life.
The 128-bit randomness space is larger than ULID's (80 bits) and UUID v4's (122 bits), making collisions even less likely. According to the official GitHub repository, trillions of KSUIDs were generated in Segment's production systems without observed collisions (segmentio/ksuid, GitHub).
UUID v7: the standard that unifies everything
UUID v7, standardized by RFC 9562 in May 2024, incorporates the lessons learned from ULID, KSUID and other time-ordered formats. It places a 48-bit Unix timestamp (millisecond precision) in the most significant bits, followed by random bits in the remaining 74 bits (after the 6 bits for version and variant).
The decisive advantage of UUID v7 over ULID and KSUID is that it is a standard UUID. Libraries, frameworks, databases and tools that already support the UUID type work with UUID v7 without modification. PostgreSQL 18 (expected for September 2025) includes native support with the uuidv7() function (PostgreSQL Documentation, "UUID v7 Support in PostgreSQL 18").
The table below compares the characteristics of the three formats:
| Characteristic | ULID | KSUID | UUID v7 |
|---|---|---|---|
| Size (bits) | 128 | 160 | 128 |
| Timestamp (bits) | 48 | 32 | 48 |
| Temporal resolution | millisecond | second | millisecond |
| Randomness (bits) | 80 | 128 | ~74 |
| Text representation | 26 chars (Base32) | 27 chars (Base62) | 36 chars (hex UUID) |
| Native UUID compatible | yes (binary) | no | yes (native) |
| Standardized by RFC | no | no | yes (RFC 9562) |
For new projects starting today, UUID v7 is the safest recommendation in most scenarios. It offers the combination of open standard, wide tooling support, temporal ordering and compatibility with the existing UUID ecosystem.
The problem UUID v7 creates in distributed databases
UUID v7 solves the B-tree performance problem by making identifiers monotonically increasing. That same property creates a different problem in databases like DynamoDB, Cassandra and CockroachDB.
In those systems, data is partitioned across nodes using a partition key. Writes and reads are routed to the node responsible for the key range containing that value. When the partition key is monotonically increasing, as UUID v7 is (the 48 timestamp bits occupy the most significant bits), all writes within any time window are directed to the same node. One node receives the entire write load while the others sit idle. This is a hot partition, and it is one of the most common causes of throttling in DynamoDB-based systems.
AWS documentation describes the problem directly: "If your table has a very large number of items but the application is sending all of its traffic to one partition key value, you might receive a ProvisionedThroughputExceededException even if the overall throughput of the table is within the provisioned limits." (AWS Documentation, "Best Practices for Designing and Using Partition Keys Effectively"). The cause is always the same: concentration of access on a subset of partitions, regardless of how much capacity the table has in aggregate.
The irony is precise: the property that makes UUID v7 excellent for PostgreSQL's B-tree (all insertions arrive at the rightmost leaf page, maximizing cache locality and eliminating page splits) is the same property that makes it a problem as a partition key in distributed databases (all insertions arrive at the same node, overloading it). B-trees benefit from writes that always go to the same place. Distributed hash tables benefit from writes that spread across different places. No single identifier format satisfies both properties simultaneously.
Four architectural responses exist for this problem, each with distinct trade-offs.
Using UUID v7 as a sort key instead of a partition key is the most natural. In DynamoDB, the composite primary key consists of a partition key and an optional sort key. Using a domain attribute (tenant ID, entity type, region) as the partition key and UUID v7 as the sort key distributes writes per tenant while preserving temporal ordering within each partition. It is the recommended pattern for append-heavy workloads on DynamoDB and works well when domain partitioning is natural.
Hash prefixing distributes writes artificially: adding a few bits derived from some record attribute to the beginning of the UUID v7 before using it as a partition key spreads traffic across partitions while preserving relative ordering within each bucket. The cost is the loss of global ordering across partitions. To reconstruct a globally ordered sequence, reads need to merge-sort results from all partitions.
Accepting UUID v4 for write-intensive tables in distributed databases and using a separate timestamp column for ordering is the lowest-effort option in legacy systems. It restores random distribution and therefore uniform partition utilization, at the cost of losing the temporal information embedded in the identifier.
Partition keys based on time buckets β such as truncation by date or hour β combined with UUID v7 as a sort key limit the hot partition problem to the duration of the bucket and expire old partitions naturally in time-series workloads.
The practical consequence for architectural decisions: UUID v7 as a primary key in a relational database (PostgreSQL, MySQL with InnoDB) is the correct pattern for new systems in most scenarios. UUID v7 as a partition key in DynamoDB or as a clustering key in Cassandra requires deliberate design to avoid hot partitions. The right choice depends on the internal data structures of the destination database, not on the identifier format in isolation.
7. Snowflake: coordinated generation at Twitter scale
The origin of the problem
In 2010, Twitter faced a significant architectural transition. The company was migrating from MySQL as its primary online store to Cassandra (a distributed database with no native support for sequential ID generation) and horizontally sharded MySQL. The team needed a system that could generate tens of thousands of IDs per second with high availability, with those IDs being approximately time-ordered and fitting in 64 bits.
The motivation for 64 bits was practical. The team had already gone through the painful experience of increasing the size of tweet IDs, and with more than 100,000 different codebases consuming the Twitter API, any change in ID format was extremely costly. 128-bit UUIDs were considered but rejected for this reason (Twitter Engineering, "Announcing Snowflake", 2010).
The ID structure
The solution was Snowflake, an open source service that generates 64-bit IDs composed of three parts:
0 | 00000000 00000000 00000000 00000000 00000000 0 | 00000 00000 | 000000000000
| 41 bits | 10 bits | 12 bits
| Timestamp (ms) | Machine ID | SequenceThe timestamp occupies 41 bits and represents milliseconds since a custom epoch (November 4, 2010, 01:42:54 UTC for Twitter). With 41 bits, the system supports approximately 69 years of timestamps before exhausting. The Machine ID occupies 10 bits (often split into 5 bits for datacenter and 5 bits for worker), allowing up to 1,024 simultaneous generators. The Sequence occupies 12 bits and is a counter that resets every millisecond, allowing up to 4,096 IDs per millisecond per machine.
The result is that each machine can generate IDs independently (no network coordination on each generation), and IDs are naturally ordered by time with the guarantee that tweets published within a second will have IDs in proximity in the ID space. The Twitter team described this level of ordering as k-sorted, where k is at most 1 second.
Adaptations by other companies
The Snowflake format was adopted and adapted by several companies. Discord uses a version with an epoch of January 1, 2015. Instagram created a variation that runs the logic directly within PostgreSQL using PL/pgSQL, eliminating the need for a separate service.
The Instagram solution is particularly interesting for PostgreSQL operators. The team delegated ID generation to each table within each shard, using a PL/pgSQL function that combines timestamp (41 bits), shard ID (13 bits) and an auto-incremented sequence modulo 1024 (10 bits). With this distribution, each shard can generate 1,024 IDs per millisecond.
-- Instagram ID generation function (simplified)
CREATE OR REPLACE FUNCTION insta5.next_id(OUT result bigint) AS $$
DECLARE
our_epoch bigint := 1314220021721;
seq_id bigint;
now_millis bigint;
shard_id int := 5;
BEGIN
SELECT nextval('insta5.table_id_seq') %% 1024 INTO seq_id;
SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis;
result := (now_millis - our_epoch) << 23;
result := result | (shard_id << 10);
result := result | (seq_id);
END;
$$ LANGUAGE PLPGSQL;The Instagram team evaluated Twitter's Snowflake but decided the additional complexity of operating a separate service was not justified for their scenario. In the words of their engineering blog post, the team sought simple and easy-to-understand solutions, because that approach was one of the reasons Instagram could scale with few engineers (Instagram Engineering, "Sharding & IDs at Instagram", 2012).
The clock drift problem
The Achilles' heel of Snowflake (and of any timestamp-based format) is the dependence on synchronized clocks. If a machine's clock is ahead and NTP (Network Time Protocol) corrects the drift by moving the clock backward, Snowflake can generate IDs with timestamps in the past, violating the ordering property.
The original Snowflake GitHub repository addresses this explicitly: if the clock moves backward, Snowflake refuses to generate IDs until time passes the last timestamp used. This is a protection measure against duplicate IDs, but it means the service is temporarily unavailable for that worker. The recommendation is to use NTP configured to never move the clock backward (only slow its advance until it converges), a practice described as "slew mode" in the NTP documentation (twitter-archive/snowflake, GitHub).
The problem intensifies at global scale. NTP on local networks converges to precisions of 1 to 10 milliseconds, but on WAN connections between continents the typical uncertainty is between 80 and 200 milliseconds. In active-active architectures, where two datacenters in different regions simultaneously accept writes, this imprecision means that Snowflake's ordering guarantee is regional, not global. Two events generated at the same time in Europe and Asia will have timestamps determined by the local clocks of each region. If those clocks diverge by 150ms, the global sequence of IDs does not reflect the actual sequence of events. For systems that depend on ID ordering as a proxy for causality (for example, feeds, audit logs, event processing), this property must be understood clearly: Snowflake guarantees order within a worker, and approximately within a synchronized datacenter, but does not guarantee global order in multi-region deployments. Google Spanner addresses this problem with TrueTime, an API that encapsulates the clock uncertainty interval with hardware guarantees (atomic clocks and GPS receivers in each datacenter). Spanner uses these uncertainty bounds to delay commits until it is certain of global order. But that infrastructure is not available to most organizations, and even within the managed offering (Cloud Spanner), the programming model changes significantly.
8. Architectural strategies for ID generation
Database vs. application
An often-underestimated architectural decision is where ID generation happens. When the database generates the ID (via auto-increment or function), the application must do an INSERT and then retrieve the generated ID (using RETURNING in PostgreSQL or LAST_INSERT_ID() in MySQL). The ID only exists after the write to the database.
When the application generates the ID (using UUID v4, ULID, KSUID or any format that does not depend on the database), the ID is available before the write. This enables patterns like "create the ID, publish an event, then write to the database" that are common in event-driven architectures and systems with CQRS (Command Query Responsibility Segregation).
Application-side generation also simplifies batch operations. If you need to insert 10,000 records, you can generate all IDs in advance, prepare the complete data and perform the bulk insertion, without a round-trip to the database for each ID.
Client-side vs. server-side
In APIs, the ID can be generated by the client (the application calling the API) or by the server (the API processing the request). Client-side generation has an interesting advantage: idempotency. If the client generates the ID and sends it in the request, and the request fails due to timeout, the client can resend the same request with the same ID. The server can detect the duplicate and return the result of the first execution instead of creating a duplicate record.
This pattern is particularly valuable in payment systems and in any context where duplicate operations cause harm. The downside is that the client must use an ID format that is safe against collisions (such as UUID v7), and the server must trust that the client will not send malformed or intentionally duplicated IDs.
Opaque IDs with prefix: the Stripe pattern
Stripe popularized an ID design pattern that combines a semantic prefix with a random suffix. Each type of object has a distinct prefix: cus_ for customers, pi_ for payment intents, sub_ for subscriptions.
pi_3LKQhvGUcADgqoEM3bh6pslE
cus_MNlbRsTWfvcJ01
sub_df987gds98fgStripe has used this pattern since 2012. Before that, object identifiers looked like traditional UUIDs without a prefix. Accounts created before 2012 still have IDs in that old format (Paul Asjes, "Designing APIs for humans: Object IDs", Stripe Engineering Blog).
The practical advantage is immediate. When an engineer sees pi_3LKQhvGUcADgqoEM in a log, they instantly know it is a payment intent, without needing to consult any other system. When a service receives cus_MNlbRsTWfvcJ01 where it expected a payment intent, it can reject the request before even querying the database.
Clerk, an authentication company, adopted the same pattern combining Stripe-style prefixes with Segment's KSUIDs as the generating part of the ID (Clerk Engineering, "Generating sortable Stripe-like IDs with Segment's KSUIDs", 2021). This kind of composition shows that ID generation strategies can (and often should) be combined.
Public vs. internal IDs
A common practice in mature systems is to maintain two identifiers for each entity. The internal ID (a BIGINT auto-increment, for example) is used for joins, foreign keys and internal database operations. The external ID (a UUID v7, ULID or prefixed ID) is exposed in APIs and URLs.
This separation offers the best of both worlds. The database operates with sequential integers (which are excellent for indexing and joins), and the API exposes opaque identifiers (which are safe against enumeration and do not leak information about data volume).
Buildkite operated exactly this way before migrating to UUID v7 as a primary key. In the 2023 blog post, the team describes having historically used sequential primary keys for efficient indexing and UUID secondary keys for external use. The migration to UUID v7 unified the two roles into a single identifier (Buildkite Engineering, 2023).
9. IDs and observability
In distributed systems, a single user request can traverse dozens of services before producing a response. Tracing the path of a request through those services is what we call distributed tracing, and identifiers are the backbone of that mechanism.
Correlation IDs and Trace IDs
A Correlation ID (or Request ID) is an identifier generated at the system entry point (usually the API Gateway or the first service to receive the request) and propagated to all subsequent services. Each service includes this ID in its logs, allowing a search for correlation_id = "abc123" to return all logs related to that specific request, regardless of which service produced them.
Tracing systems like OpenTelemetry, Jaeger and Zipkin use Trace IDs (typically 128 bits) and Span IDs (64 bits) to build a call tree. The Trace ID identifies the request as a whole, and each Span ID identifies an individual operation within the trace (an HTTP call, a database query, a message publication to a queue).
The choice of Trace ID format matters. The W3C Trace Context standard (adopted by OpenTelemetry) uses 128 bits represented as 32 hexadecimal characters. This format is UUID-compatible, which facilitates storage and indexing in databases. If the Trace ID is a UUID v7, it intrinsically carries the timestamp of when the request was initiated, which is useful information for performance analysis and queries in observability systems.
Propagation between services
For Correlation IDs and Trace IDs to work, they need to be propagated between services. This happens via HTTP headers (like X-Request-ID or traceparent in the W3C standard), via message metadata in queues (like Kafka headers or SQS message attributes) and via context in gRPC calls (metadata).
The most common failure in this chain is a service that does not propagate the ID. If an intermediate service creates a new HTTP request without copying the Correlation ID from the original request, the trace breaks at that point. Automatic instrumentation (like that provided by OpenTelemetry libraries) mitigates this risk, but does not eliminate it completely, especially in legacy services or in integrations with third-party systems that do not support the propagation headers.
10. Real problems in production
Migrating from integers to UUIDs
One of the most feared migrations in production systems is changing the primary key type from integer to UUID (or similar). The difficulty is not just in altering the column itself, but in every table that references that column via foreign keys, in every application that consumes those IDs, in every cache that stores those values, in every index that needs to be rebuilt.
A strategy that reduces risk is the dual-write approach. The process works in phases. First, add a new column with the new ID format (without removing the old one). Second, the application starts writing to both columns simultaneously. Third, a backfill job populates the new column for old records. Fourth, consumers are migrated to use the new column. Fifth (and only after all consumers have migrated), the old column can be removed.
Each of these phases can take weeks or months in a production system with many consumers. That is why the initial choice of ID format has such a disproportionate impact: switching later is possible, but costly. The concept of evolutionary architecture, discussed by Neal Ford, Rebecca Parsons and Patrick Kua in "Building Evolutionary Architectures" (O'Reilly, 2017), frames exactly this kind of change: incremental, reversible modifications guided by fitness functions that protect the properties the system needs to maintain. Dual-write is an evolutionary pattern. Each phase is independently reversible and the system never stops working. The property that the fitness function protects here is referential consistency between the old and new columns during the transition.
The Buildkite experience with backward compatibility
Buildkite encountered an interesting backward compatibility problem during their migration. When they experimented with changing the version byte of UUIDs from "4" (v4) to "7" (v7), they discovered that some client systems that validated UUIDs rejected the new IDs because they did not recognize version 7. The temporary solution was to keep the version byte as "4" while internally using the temporal layout of v7 (Buildkite Engineering, 2023).
This kind of detail is what makes ID format migrations particularly treacherous. The change in data format is only the visible part. The effects on validators, serializers, caches, filters and any code that makes assumptions about the structure of the ID can be surprising.
Debugging with complex IDs
Opaque IDs make day-to-day debugging harder. When a support engineer receives a report of "something went wrong with my purchase" and needs to trace the problem, having to work with 01ARZ3NDEKTSV4RRFFQ69G5FAV instead of 47832 increases the friction at every step of the investigation.
Several practices mitigate this problem. Ensuring that every complex ID has some form of "shorthand" or that internal tools support prefix-based search reduces operational friction immediately. Search tools that accept any ID format and redirect to the correct record eliminate the need for the engineer to know the format before searching. The Stripe-style prefix pattern, even without solving memorization, at least eliminates ambiguity about the type of resource.
11. Security and ID exposure
What each format leaks
Sequential IDs exposed in public interfaces are vulnerable to enumeration. But each format has its own information leakage profile, and understanding what each one reveals is the first step toward mitigating the risk.
Sequential integer IDs leak volume and growth rate. An attacker who observes ID 4312 today and ID 5891 tomorrow knows the system created 1,579 records in 24 hours. That data is enough to estimate revenue, user base or traffic in public systems.
UUID v1 leaks infrastructure information. The MAC address of the generating machine is embedded in the last 48 bits of the identifier, which can help an attacker map the internal network topology. Additionally, the 100ns timestamp present in UUID v1 does not appear in the most significant bits, but can be extracted directly from the structure, revealing the moment of creation with precision.
UUID v4 and v7, when generated with a weak PRNG (Pseudo-Random Number Generator) (like JavaScript's Math.random() in certain implementations), can be partially predictable if an attacker has access to a sequence of generated IDs. RFC 9562 explicitly recommends the use of cryptographically secure generators (CSPRNG) for the random part of UUIDs. The practical difference: a CSPRNG collects entropy from the operating system (via /dev/urandom on Linux, CryptGenRandom on Windows), while a simple PRNG is deterministic from an initial seed.
Obfuscation is not access control
The most common mistake in this domain is treating the ID format as a security mechanism. A UUID v4 does not authenticate the user who holds it. It only makes guessing harder. If the API returns data for any valid UUID without checking who is making the request, the IDOR vulnerability exists regardless of the randomness of the identifier.
OWASP is explicit about this: defense against IDOR requires that every request accessing an object be validated on the server to confirm that the requesting user has authorization over that specific resource, regardless of the identifier format (OWASP, "IDOR Prevention Cheat Sheet"). The opaque identifier is the second line of defense. The first is always access control.
Defense in depth in practice
With access controls correctly implemented, the ID format offers an additional layer of protection following the defense in depth principle. Random IDs like UUID v4 or v7 make enumeration computationally infeasible. Even if an attacker discovered the generation pattern, the 122-bit entropy of UUID v4 creates a search space with approximately 5.3 Γ 10Β³βΆ possibilities. Hashes of internal IDs (using libraries like Hashids, which generate short obfuscated identifiers from integers) offer an alternative when a complete primary key migration is not viable. Temporary tokens with explicit expiration are useful for sharing URLs, like those used by Google Docs, where access must be revocable without altering the permanent identity of the resource.
12. How to choose: a decision framework
There is no ID format that is ideal for every scenario. The choice depends on the architectural context, performance requirements, compatibility constraints and the stage of evolution of the system. The diagram below is a starting point for guiding that decision:
flowchart TD
A[I need to generate IDs] --> B{Is the system distributed?}
B -->|No| C{Is write performance critical?}
C -->|No| D[BIGSERIAL / AUTO_INCREMENT]
C -->|Yes| E{Are IDs exposed externally?}
E -->|No| D
E -->|Yes| F[BIGSERIAL internal + UUID external]
B -->|Yes| G{Do IDs need to fit in 64 bits?}
G -->|Yes| H{Can I operate an ID service?}
H -->|Yes| I[Snowflake]
H -->|No| J[Snowflake-like in the database
Instagram style]
G -->|No| K{Do I need an RFC standard?}
K -->|Yes| L[UUID v7]
K -->|No| M{Do I need maximum entropy?}
M -->|Yes| N[KSUID - 160 bits]
M -->|No| O[ULID - 128 bits]Several additional questions help refine the decision. What is the database? PostgreSQL has native UUID support and will have native UUID v7 support in version 18. MySQL stores UUIDs as strings or BINARY(16), and using BINARY with conversion functions is necessary for adequate performance. DynamoDB and Cassandra work well with any binary format, but benefit from partition keys with good distribution.
What is the expected write volume? For fewer than a thousand writes per second on a single database, BIGSERIAL is sufficient. For tens of thousands of writes per second distributed across multiple services, UUID v7 or Snowflake are necessary.
Will IDs be exposed in APIs? If so, avoid sequential integers. Use UUID v7 (if the ecosystem supports it), ULID (if you need shorter representation) or Stripe-style prefixed IDs (if human readability is a priority).
Is there a need for temporal ordering embedded in the ID? If the system uses IDs for cursor-based pagination, if it needs to sort events without consulting additional fields, or if it uses event-sourcing architectures, IDs with a temporal component (UUID v7, ULID, Snowflake) are strongly recommended.
What is the tolerance for operational complexity? Snowflake requires Machine ID coordination (via Zookeeper, manual configuration or another mechanism). UUID v7, ULID and KSUID require no coordination and can be generated on any node without additional configuration.
13. Consolidated technical comparison
The table below consolidates the characteristics of the formats discussed in this article:
| Characteristic | BIGINT (auto-inc) | UUID v4 | UUID v7 | ULID | KSUID | Snowflake |
|---|---|---|---|---|---|---|
| Size | 64 bits | 128 bits | 128 bits | 128 bits | 160 bits | 64 bits |
| Temporal ordering | yes | no | yes | yes | yes | yes |
| Uniqueness without coordination | no | yes | yes | yes | yes | partial (machine ID) |
| Indexing performance (B-tree) | excellent | poor at scale | excellent | excellent | excellent | excellent |
| Human readability | high | low | low | medium | medium | medium |
| Enumeration resistance | none | high | high | high | high | low-medium |
| Standardized by RFC | N/A | RFC 9562 | RFC 9562 | no | no | no |
| Decentralized generation | no | yes | yes | yes | yes | yes (with machine ID) |
| Extractable temporal information | no | no | yes | yes | yes | yes |
| Native PostgreSQL support | yes | yes | yes (v18+) | via UUID type | no | no |
14. Implementation examples
UUID v7 in PostgreSQL 18+
-- PostgreSQL 18 (native)
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuidv7(),
type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMP DEFAULT now()
);
-- Insertion: the database generates the UUID v7 automatically
INSERT INTO events (type, payload)
VALUES ('order.created', '{"order_id": 42}');
-- Extract timestamp from UUID v7 (PostgreSQL 18)
SELECT id, uuid_extract_timestamp(id) AS generated_at
FROM events
ORDER BY id
LIMIT 5;UUID v7 in pre-18 PostgreSQL (via extension)
-- Install the pg_uuidv7 extension
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
type TEXT NOT NULL,
payload JSONB
);Snowflake-like in Python
import time
import threading
class SnowflakeGenerator:
"""
Snowflake-style ID generator.
Layout: 1 bit (sign) + 41 bits (timestamp) + 10 bits (machine) + 12 bits (sequence)
"""
EPOCH = 1288834974657 # Twitter epoch: Nov 04 2010 01:42:54 UTC
MACHINE_BITS = 10
SEQUENCE_BITS = 12
MAX_MACHINE = (1 << MACHINE_BITS) - 1 # 1023
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1 # 4095
def __init__(self, machine_id: int):
if machine_id < 0 or machine_id > self.MAX_MACHINE:
raise ValueError(f"machine_id must be between 0 and {self.MAX_MACHINE}")
self.machine_id = machine_id
self.sequence = 0
self.last_timestamp = -1
self.lock = threading.Lock()
def _current_millis(self) -> int:
return int(time.time() * 1000)
def generate(self) -> int:
with self.lock:
timestamp = self._current_millis()
if timestamp < self.last_timestamp:
# Clock drift detected: wait until timestamp advances
raise Exception("Clock moved backwards")
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & self.MAX_SEQUENCE
if self.sequence == 0:
# Exhausted all 4096 IDs in this millisecond: wait for next
while timestamp <= self.last_timestamp:
timestamp = self._current_millis()
else:
self.sequence = 0
self.last_timestamp = timestamp
return (
((timestamp - self.EPOCH) << (self.MACHINE_BITS + self.SEQUENCE_BITS))
| (self.machine_id << self.SEQUENCE_BITS)
| self.sequence
)
# Usage:
gen = SnowflakeGenerator(machine_id=1)
new_id = gen.generate()
print(f"Generated ID: {new_id}")ULID in different languages
// JavaScript (with the ulid library)
import { ulid } from 'ulid';
const id = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"# Python (with the python-ulid library)
from ulid import ULID
id = ULID()
print(str(id)) # "01ARZ3NDEKTSV4RRFFQ69G5FAV"
print(id.timestamp) # timestamp embedded as float
print(id.datetime) # datetime extracted from timestamp// Go (with the oklog/ulid library)
import "github.com/oklog/ulid/v2"
id := ulid.Make()
fmt.Println(id.String()) // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
fmt.Println(id.Time()) // timestamp in millisecondsPrefixed IDs (Stripe style)
import uuid
def generate_prefixed_id(prefix: str) -> str:
"""
Generates an ID with a semantic prefix and UUID v7 suffix (or v4 as fallback).
Example: cus_550e8400e29b41d4a716446655440000
"""
# In production, use a UUID v7 library
raw_id = uuid.uuid4().hex # 32 hex characters, no hyphens
return f"{prefix}_{raw_id}"
# Usage:
customer_id = generate_prefixed_id("cus") # cus_550e8400e29b41d4a716446655440000
payment_id = generate_prefixed_id("pay") # pay_7f3b8a2c1d4e5f6a7b8c9d0e1f2a3b4c
order_id = generate_prefixed_id("ord") # ord_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d615. Conclusion: inevitable trade-offs and architecture as a process
Every identifier choice is a trade-off. Sequential integers offer performance and simplicity, but fail in distributed systems and leak information when exposed. UUID v4 offers global uniqueness without coordination, but degrades B-tree performance at scale. UUID v7, ULID and KSUID combine the best properties, but take up more space and require libraries or newer database versions. Snowflake IDs fit in 64 bits and offer ordering, but require machine ID coordination and are sensitive to clock drift.
The most common mistake is not choosing the "wrong" format, but making the choice without considering the future of the system. A project that starts as a monolith and evolves into microservices will need IDs that can be generated in a decentralized way. An internal API promoted to a public API will need opaque IDs. A system that works well with 100,000 records may behave completely differently with 100 million.
The most pragmatic approach is to start with the simplest solution that meets the known requirements, but invest time understanding the likely evolution scenarios. If there is any chance the system will be distributed in the future, considering UUID v7 as the default from the start is a decision that will rarely be regretted. If write performance is the primary concern and the system is controlled by a single team, Snowflake or variants offer the best cost-benefit ratio.
ID generation is an architectural decision with long-term implications for performance, security, operability and system evolution. The earlier this decision receives the attention it deserves, the lower the cost of adaptation when (not if) conditions change.
References
- Twitter Engineering. "Announcing Snowflake." 2010. https://blog.x.com/engineering/en_us/a/2010/announcing-snowflake
- Instagram Engineering. "Sharding & IDs at Instagram." 2012. https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c
- Flickr Engineering. "Ticket Servers: Distributed Unique Primary Keys on the Cheap." 2010. https://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/
- Buildkite Engineering. "Goodbye to sequential integers, hello UUIDv7!" 2023. https://buildkite.com/resources/blog/goodbye-integers-hello-uuids/
- Segment Engineering (Twilio). "A Brief History of the UUID." 2017. https://segment.com/blog/a-brief-history-of-the-uuid/
- Paul Asjes (Stripe). "Designing APIs for humans: Object IDs." https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a
- Clerk Engineering. "Generating sortable Stripe-like IDs with Segment's KSUIDs." 2021. https://clerk.com/blog/generating-sortable-stripe-like-ids-with-segment-ksuids
- IETF. RFC 9562: "Universally Unique IDentifiers (UUIDs)." 2024. https://datatracker.ietf.org/doc/html/rfc9562
- OWASP. "Insecure Direct Object Reference Prevention Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html
- Wikipedia. "Insecure direct object reference." https://en.wikipedia.org/wiki/Insecure_direct_object_reference
- Segment. "segmentio/ksuid" (GitHub repository). https://github.com/segmentio/ksuid
- Twitter. "twitter-archive/snowflake" (GitHub repository). https://github.com/twitter-archive/snowflake
- Better Stack. "UUID v7 in PostgreSQL 18." https://betterstack.com/community/guides/databases/postgresql-18-uuid/
- PostgreSQL Documentation. "B-Tree Indexes." https://www.postgresql.org/docs/current/btree.html