Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

UUID as primary key

On this page
  1. The classic case against UUIDs
    1. 1. Index fragmentation
    2. 2. Storage overhead
    3. 3. Readability
  2. The case for UUIDs
    1. 1. No coordination required
    2. 2. No information leak
    3. 3. Mergeable systems
    4. 4. Stable across environments
  3. How v7 changes the math
  4. Decision matrix
  5. Performance numbers (typical PostgreSQL workload)
  6. Common compromise: separate public ID
  7. What about clustered indexes (SQL Server)?
  8. What about MySQL / InnoDB?
  9. What about NoSQL (DynamoDB, Mongo, Firestore)?
  10. When NOT to use UUIDs
  11. Migration: existing v4 → v7
  12. Try the tools
  13. Related across the network

For 20 years the conventional wisdom was: “Don’t use UUIDs as primary keys.” That advice was right at the time. It’s now mostly wrong, because of UUID v7.

This article explains the trade-offs, the v4-vs-v7 difference, and when an auto-increment integer is still the right call.

The classic case against UUIDs

Three concerns, in decreasing order of severity:

1. Index fragmentation

Random UUIDs (v4) inserted into a B-tree index land at random positions. Each insert touches a different leaf page, dirties it, and may cause a page split. On a large table:

For a 100M-row table, this can be 2–5× slower than the same table with monotonic integer keys.

2. Storage overhead

A bigint is 8 bytes. A uuid is 16 bytes. Every foreign key column is twice as wide. Every index is twice as wide. For a deeply-related schema this adds up — though typically to single-digit-percent overhead at scale, not catastrophe.

3. Readability

/users/42 is human-comprehensible. /users/0e6f1b8c-2c33-4f1f-9c0b-2a3d4e5f6a7b is not. Logs, error messages, and ad-hoc SQL queries are all marginally harder to scan. (This is also a privacy advantage — see below.)

The case for UUIDs

1. No coordination required

Two services, two databases, two offline mobile clients can all generate IDs that never collide. With auto-increment, you need a central authority — a single sequence service, or careful manual partitioning.

2. No information leak

/users/42 tells someone there are 41 users before me. /orders/1000123 tells a competitor your daily order volume. UUIDs reveal nothing about magnitude or order.

3. Mergeable systems

Acquiring a company? Importing data from a competitor? Their UUIDs slot into your tables without renumbering. Their auto-increment IDs collide on day one.

4. Stable across environments

A row created in dev keeps the same ID when copied to staging or prod. Auto-increment IDs do not.

How v7 changes the math

UUID v7 puts a 48-bit Unix-millisecond timestamp at the front, followed by 74 random bits. Sequentially-generated v7 UUIDs are roughly monotonic — they cluster on the rightmost B-tree leaf, the same way auto-increment integers do.

This eliminates concern #1 above. Insert performance with v7 is within ~10% of bigserial/bigint on Postgres for typical workloads. The “UUIDs fragment indexes” critique applied to v4. It does not apply to v7.

v4 inserts:  random distribution → page splits everywhere → slow
v7 inserts:  monotonic prefix → rightmost-page-only → ~native speed

The other concerns (storage, readability) still apply, but they’re orders of magnitude smaller than the index-fragmentation hit.

Decision matrix

ConstraintRecommendation
Single Postgres instance, single app, no offline clientsbigint identity is fine
Multiple services or databasesUUID v7
Public-facing URLs reveal IDsUUID (any version)
Mobile app with offline ID creationUUID v7
Inserts are < 1000/sec, table < 10M rowsEither is fine
Inserts are >100K/sec, table >100M rowsUUID v7 or bigint identity
Greenfield, no specific reason either wayUUID v7 (it’s the boring future-proof choice)

Performance numbers (typical PostgreSQL workload)

These are order-of-magnitude — your numbers will vary based on hardware, schema, and access pattern:

ApproachInsert rate (rows/sec)Index size at 100M rows
bigint identity100% (baseline)100%
uuid with uuidv7()~92%~150%
uuid with gen_random_uuid() (v4)~30–50%~180–220% (with bloat)
uuid with v4, repacked weekly~40–60%~160%

The v4 row is the one that gave UUIDs a bad name. Use v7 instead.

Common compromise: separate public ID

Some teams use a bigint primary key internally and add a UUID for external exposure:

CREATE TABLE orders (
    id bigserial PRIMARY KEY,                       -- internal, fast joins
    public_id uuid UNIQUE NOT NULL DEFAULT uuidv7() -- external URLs
);

Pros: peak insert performance, clean URLs. Cons: two indexes, two columns to keep in sync, two ways to mistakenly query.

This is a fine choice for very high-throughput tables. For most apps, just use uuid v7 as the primary key and skip the second column.

What about clustered indexes (SQL Server)?

SQL Server clusters tables by their primary key by default. Random v4 Guids as a clustered index are particularly painful — every insert physically rewrites a page. With v7 (Guid.CreateVersion7() in .NET 9+) or NEWSEQUENTIALID(), the clustered index stays sorted.

-- Bad on SQL Server clustered index
CREATE TABLE Orders (Id uniqueidentifier PRIMARY KEY DEFAULT NEWID(), ...);

-- Good — sequential variant
CREATE TABLE Orders (Id uniqueidentifier PRIMARY KEY DEFAULT NEWSEQUENTIALID(), ...);

-- Good — v7 from app code (.NET 9+)
INSERT INTO Orders (Id, ...) VALUES (@id, ...); -- @id from Guid.CreateVersion7()

If you can’t use v7 yet (old .NET), use NEWSEQUENTIALID() as a default only. Generated v4 Guids passed in from the app are still bad.

What about MySQL / InnoDB?

InnoDB clusters by primary key just like SQL Server. The same advice applies: UUID v7 ✅, v4 ❌. Store as BINARY(16) (not CHAR(36)) for half the storage.

-- Recommended
CREATE TABLE orders (
    id BINARY(16) PRIMARY KEY,  -- v7 generated in app code
    ...
);

-- Helper functions for human-readable I/O
SELECT BIN_TO_UUID(id) FROM orders;

What about NoSQL (DynamoDB, Mongo, Firestore)?

These don’t have B-tree clustered primary keys, so the v4 fragmentation argument doesn’t apply. Use whatever ID your team prefers. UUIDs are the safe default; ULID and nanoid are reasonable for shorter strings.

When NOT to use UUIDs

Migration: existing v4 → v7

You don’t need to backfill old rows. Just change the default for new ones:

ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();

Existing v4 IDs stay v4 forever. New rows get v7. The table will be a mix until old data ages out, which is fine — the version is encoded in each ID, so anything that needs to know can check.

Try the tools