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

UUID vs auto-increment

On this page
  1. The two designs
  2. Where auto-increment wins
  3. Where UUIDs win
  4. Performance — the v4 vs v7 distinction
  5. When auto-increment is the right call
  6. When UUIDs are the right call
  7. Common compromise: bigint internally, UUID externally
  8. ”But UUIDs make joins slower”
  9. Migration path
  10. Decision flowchart
  11. Try the tools

TL;DR. Auto-increment is smaller (8 bytes vs 16) and slightly faster. UUIDs work without coordination, hide row count, and survive system merges. For most new systems, UUID v7 is the right default — the storage cost is tiny and the operational benefits compound. Auto-increment still wins for small, single-database systems where simplicity is the goal.

The two designs

Auto-increment (bigserial / IDENTITY / AUTO_INCREMENT)UUID v7
Bytes per ID816
Generated bydatabase (next sequence value)application or DB
Coordinationcentral sequence/identity servicenone
Sortableyes (numerically)yes (lexicographically)
Reveals row countyesno
Survives database mergeonly after renumberingyes
Survives offline-first clientshardtrivial
Reading a /users/N URL givesordered, predictableopaque

Where auto-increment wins

Where UUIDs win

Performance — the v4 vs v7 distinction

Old advice said UUIDs were “slow as primary keys.” That was true for v4 because random insertion fragments B-tree indexes. With v7 (time-ordered), insertion clusters at the rightmost leaf, just like auto-increment.

Approximate benchmarks on PostgreSQL (insert rate, 100M-row table):

ApproachInserts/sec (single connection)Index size at 100M rows
bigserial100% (baseline)100%
uuid v7 (uuidv7() in PG 18)~92%~150%
uuid v4 (gen_random_uuid())~30–50%~180–220%

So the conventional wisdom needs an asterisk: v4 UUID has the cost; v7 is within 10% of bigserial. The 16-byte storage overhead is real but typically not a deciding factor.

When auto-increment is the right call

When UUIDs are the right call

Common compromise: bigint internally, UUID externally

Some teams use both:

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

Pros: peak insert performance for joins, opaque public URLs. Cons: two indexes, two columns, two ways to query, two ways to make mistakes.

For most apps, just use uuid v7 as the primary key and skip the second column. The performance difference is small and the simplicity matters.

”But UUIDs make joins slower”

Joins compare 16 bytes vs 8 bytes per row. In practice, the page-level access patterns dominate, not the per-row comparison cost. Modern CPUs compare 16-byte values in a single SIMD instruction. The overhead is usually < 5% on real workloads.

If your benchmarks show otherwise, the cause is almost certainly v4 fragmentation, not the comparison itself. Switch to v7.

Migration path

If you have an existing auto-increment table and want to add UUIDs:

-- Postgres example
ALTER TABLE orders ADD COLUMN public_id uuid UNIQUE NOT NULL DEFAULT uuidv7();

This adds a second identifier alongside the existing id. Use public_id in URLs and external APIs going forward; keep id for internal joins.

To fully migrate away from auto-increment requires renumbering every foreign key — which is rarely worth doing on a live system. Add UUIDs alongside; don’t replace.

Decision flowchart

Are you greenfield?
└── UUID v7 (lower friction over time)

Is your app single-DB, single-service, no offline clients,
no public-facing IDs?
└── bigserial (simpler, smaller, faster)

Do you have multiple services / databases / mobile clients?
└── UUID v7 (the coordination story is the headline benefit)

Do you have public-facing IDs that should be unguessable?
└── UUID (v4 for unguessability, v7 for sortable+unguessable)

Is index size a measured bottleneck at scale?
└── bigserial OR (bigserial internal + uuid external)

Try the tools