Composite Keys vs Surrogate Keys: The Decision That Haunts Your Migrations
A primary key is the hardest thing in a schema to change, because every foreign key, index, URL, and cache key copies it. Natural composite keys enforce uniqueness for free but ripple through every child table when they change; surrogate keys stay stable but quietly allow duplicates unless you add the right constraint. Here's how each choice plays out over years of migrations, and which to use where.
The Email Address That Was a Primary Key
Early on, it seemed obvious. Every user has a unique email, so users used email as its primary key. Orders referenced users by email. Order items referenced orders by (customer_email, order_no). No arbitrary IDs anywhere, and uniqueness was enforced by the keys themselves.
Then a user asked to change their email address. The UPDATE on users failed because orders referenced the old value. Someone added ON UPDATE CASCADE, and the change went through, rewriting the user row, 3,000 order rows, and 11,000 order item rows in one transaction while holding locks on all of them. Support tickets for email changes became an operational event.
A year later the company signed its first enterprise customer and needed every table to be scoped by tenant. Every primary key and every foreign key had to grow a tenant_id column. The migration plan ran to forty steps.
The primary key is the one decision in your schema that everything else copies. Foreign keys copy it, indexes copy it, API URLs and caches and other services copy it. Changing it later means changing all of those.
The Vocabulary
A key made from data that has meaning in the real world: an email address, an ISBN, a country code, an order number printed on an invoice.
A key made of more than one column, like (order_id, line_no) or (tenant_id, user_id). Composite keys are often natural (built from meaningful columns), but they don't have to be.
A key with no business meaning, generated only to identify the row: an auto-incrementing integer, a sequence, or a UUID. It never needs to change, because nothing about it is tied to the real world.
These aren't mutually exclusive. A common, solid design is a surrogate primary key plus a unique constraint on the natural key. Many arguments about "natural vs surrogate" go away once you realize you usually want both.
Why the Key Choice Spreads
A primary key doesn't stay in its own table. Here's where a customer's key ends up in a modest schema:
Three consequences follow from this:
- If the key changes, every copy changes. Inside the database,
ON UPDATE CASCADEcan do this for you, at the cost of rewriting and locking every referencing row. Outside the database, in URLs, logs, caches, and other services' tables, nothing can. - If the key is wide, every copy is wide. A 30-byte composite key in a parent becomes 30 bytes in every child row and every index on those columns.
- If the key's structure changes, every referencing table changes. Adding a column to a composite primary key means altering every foreign key that points at it.
๐ก The InnoDB multiplier
In MySQL's InnoDB, the table is the primary key index (a clustered index), and every secondary index stores the primary key value as its pointer back to the row. A wide primary key makes every secondary index on that table wider too. PostgreSQL stores rows in a separate heap and its indexes point at physical row locations, so a wide key there costs space in its own index and in child tables' foreign key columns, but doesn't inflate other indexes on the same table.
The Case for Natural and Composite Keys
Natural keys have real advantages, and the strongest arguments for them are about correctness.
Uniqueness comes built in. If the primary key is (user_id, group_id) on a membership table, you physically can't add the same user to the same group twice. With a surrogate id and no other constraint, you can, and you will.
Fewer joins. If order_items carries customer_email as part of its key, you can find a customer's items without joining through orders. That's sometimes a real win for read-heavy paths.
The data explains itself. A row in country_currencies keyed by ('PK', 'PKR') is readable without looking anything up.
Composite keys can enforce relationships surrogates can't. This is the most underrated one. In a multi-tenant database, you want to guarantee that an order can only reference a customer in the same tenant. With surrogate keys alone, orders.customer_id can point at any customer in any tenant. With composite keys, it can't:
CREATE TABLE customers ( tenant_id bigint NOT NULL, id bigint GENERATED ALWAYS AS IDENTITY, PRIMARY KEY (tenant_id, id) ); CREATE TABLE orders ( tenant_id bigint NOT NULL, id bigint GENERATED ALWAYS AS IDENTITY, customer_id bigint NOT NULL, PRIMARY KEY (tenant_id, id), -- the order's tenant must match the customer's tenant FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id) );
That's a composite key built from surrogate parts, and it closes off a whole class of cross-tenant data leaks at the schema level. It also matches what sharded and distributed databases need: systems like Citus require the distribution column (here, tenant_id) to be part of every primary key and unique constraint, so rows for one tenant stay together on one node. (Database Sharding explains why.)
The Case Against Natural Keys
The problem with natural keys is that the real world doesn't keep its promises.
Natural keys change. People change their email addresses and usernames. Companies rebrand and change stock tickers. Books moved from 10-digit to 13-digit ISBNs in 2007. Products get re-numbered when a catalog is reorganized. Data that looks permanent today has a habit of becoming editable once the business grows.
"Unique" turns out not to be. Two customers share an email address because they're a family. A supplier reuses part numbers across product lines. An order number is unique per store, not globally, so the "natural key" was really (store_id, order_no) all along.
Natural keys leak information. If the key appears in URLs, an email-keyed resource puts personal data in access logs, browser history, and referrer headers.
Composite keys make every child wider and every migration bigger. Each level of a hierarchy inherits its parent's full key plus its own columns. By the third level, a foreign key might be four columns, and every JOIN has to name all of them correctly.
The Case for (and Against) Surrogate Keys
Surrogate keys solve the stability problem completely. An id never has a reason to change, so nothing that references it ever has to be rewritten. Foreign keys are one narrow column. Your ORM, your API, and your caches all get a simple, uniform identifier.
The costs are real but more manageable:
- Duplicates. A surrogate key guarantees each row is unique, not each thing. Without a
UNIQUEconstraint on the natural key, inserting the same customer twice succeeds, with two different IDs. This is the most common mistake in surrogate-key schemas. - An extra index. That unique constraint is a second index you have to maintain. It's almost always worth it.
- Extra joins. Getting from an order item to the customer's email means joining through orders and customers instead of reading a column.
- IDs that say too much. A sequential integer reveals roughly how many rows you have, and makes neighboring IDs trivial to guess. That's not a vulnerability by itself, but it turns a missing authorization check into an enumerable one. (Insecure Direct Object References covers this.)
Integer or UUID?
| Type | Size | Insert pattern | Notes |
|---|---|---|---|
| bigint identity / sequence | Size8 bytes | Insert patternAlways appends to the right edge of the index | NotesCompact and fast. Needs the database to generate it. Sequential IDs are guessable. |
| UUIDv4 (random) | Size16 bytes | Insert patternRandom position in the index | NotesCan be generated anywhere. Random inserts touch pages all over the index, causing more page splits and cache misses on large tables. |
| UUIDv7 (time-ordered) | Size16 bytes | Insert patternRoughly appends, like a sequence | NotesDefined in RFC 9562 (2024). Generated anywhere, index-friendly. Its prefix reveals creation time. PostgreSQL 18 added a built-in uuidv7() function. |
If you need IDs generated outside the database (by clients, across services, before the row exists), UUIDv7 is now the sensible default. If the database always generates IDs and you're not exposing them, bigint is smaller and simpler. UUIDv4 is mainly worth it when you specifically don't want IDs to reveal ordering or time.
โ ๏ธ Don't use int when you mean bigint
A 32-bit int primary key tops out at about 2.1 billion. That sounds like a lot until a high-volume events table, or a sequence that skips values on rollbacks and retries, reaches it. Migrating a primary key column from int to bigint on a large, busy table is a multi-day project. The extra 4 bytes per row is cheap insurance.
Watch a Key Change Ripple
The simulator uses a three-level schema (customers, orders, order items) with each key strategy. Change the strategy and the number of orders the customer has, and toggle the unique constraint on email.
customers PK (email) orders PK (customer_email, order_no) order_items PK (customer_email, order_no, line_no)
A composite natural key copies the parent's key into every child, so a change to it ripples down the hierarchy. A surrogate key keeps that change to one row, but only a UNIQUE constraint on the natural key stops duplicates. Index sizes are key bytes only, for the primary key plus two secondary indexes that each store it.
Things to notice:
- Composite natural keys with 5,000 orders. One email change rewrites over 20,000 rows across three tables. With any surrogate key, it rewrites one.
- Surrogate bigint, unique constraint off. Everything looks better, except that duplicate customers are now allowed. Turn the constraint on to fix it.
- UUIDv4 vs UUIDv7. Same size, but v4 lands new rows at random spots in the index while v7 appends. On a large table this is the difference between a hot, cached right edge and cache misses across the whole index.
- UUIDs vs the composite key on index size. A UUID is still smaller than a composite key built around an email address, but twice the size of a
bigint, and in InnoDB that difference repeats in every secondary index.
The Migrations That Hurt
The cost of a key decision rarely shows up at design time. It shows up in these migrations:
The natural key needs to change
With a natural primary key, you either cascade updates through every child table (big locking transactions) or you do a staged migration: add a surrogate column, backfill it everywhere, switch foreign keys over, then switch the primary key. Both are expensive. With a surrogate key, changing the email is a one-row UPDATE that a unique constraint keeps honest.
The key needs another column
Going multi-tenant or adding a partitioning column means the primary key changes shape. With composite keys, every table that references it needs its foreign key rebuilt to match. With surrogate keys, existing keys usually stay; you add the new column, index it, and possibly add composite unique constraints for tenant-safe references as shown above.
The key type is too small
int to bigint, or integers to UUIDs for a public API. Every referencing column has to change type too, and on large tables each change is a table rewrite. The usual zero-downtime approach is a new column, dual writes, a backfill, and a cutover, repeated for every referencing table. (Zero-Downtime Database Migrations walks through that pattern.)
Two systems need to merge
Merging two databases with sequential IDs means both have a customer #1042. One side gets renumbered, and every external reference to the renumbered IDs breaks. Globally unique IDs (UUIDs) avoid the collision entirely.
Where Each Belongs
You don't have to pick one approach for the whole schema. Most well-designed schemas mix them:
| Table | Recommended key | Why |
|---|---|---|
| Core entities (users, orders, products) | Recommended keySurrogate PK + UNIQUE on natural key | WhyStable references, no duplicates, narrow foreign keys |
| Pure join tables (user_roles, post_tags) | Recommended keyComposite PK of the two foreign keys | WhyUniqueness for free, no extra column or index; add a reverse index for lookups the other way |
| Child rows that only exist inside a parent | Recommended keyComposite (parent_id, line_no) or surrogate | WhyComposite reads naturally; surrogate if children get referenced elsewhere |
| Multi-tenant or sharded data | Recommended keyComposite (tenant_id, id) with surrogate id | WhyTenant-safe foreign keys and co-location on one shard |
| Small, stable reference data | Recommended keyNatural key (ISO country or currency codes) | WhyCodes rarely change, and when they do it's a planned event |
Is this a join table whose only job is linking two other tables?
Key Takeaways
- The primary key gets copied everywhere. Into foreign keys, indexes, URLs, caches, and other systems. Its stability and width affect all of them.
- Natural keys change more than you expect. Emails, usernames, product codes, and "unique" business numbers all turn out to be editable or duplicated eventually.
- Composite keys widen every child and every migration. Each level of a hierarchy inherits the full parent key, and changing the key's shape means rebuilding every reference.
- A surrogate key alone doesn't prevent duplicates. Pair it with a
UNIQUEconstraint on the natural key. This combination is the right default for most entity tables. - Composite keys still have strong uses. Join tables, and
(tenant_id, id)for multi-tenant data, where composite foreign keys stop references from crossing tenants. - Pick the surrogate type deliberately.
bigintfor database-generated, internal IDs; UUIDv7 when IDs are generated elsewhere; never 32-bitintfor a table that might grow.
References
- RFC 9562: Universally Unique IDentifiers (UUIDs), which defines UUIDv7
- PostgreSQL docs: UUID Functions, including
uuidv7()(PostgreSQL 18+) - MySQL docs: Clustered and Secondary Indexes, on how InnoDB secondary indexes store the primary key
- PostgreSQL docs: Foreign Keys, including composite foreign keys and
ON UPDATE CASCADE - Citus docs: Distributed table constraints, on including the distribution column in unique constraints