Auto-Increment or UUID: Picking a Primary Key You Won't Regret

The primary key is the cheapest decision on day one and the most expensive one to reverse. What auto-increment integers leak, what UUIDs cost, and the hybrid that avoids the tradeoff entirely.

The column you can't change without a war

Every table starts with a primary key, and the choice usually gets made by whatever the framework scaffolded. That's fine, because the key itself is cheap. It's cheap until you need to change it: altering a primary key means rewriting the table, every foreign key that points at it, every join index, and a migration plan for a live system. So the question is worth ten minutes of actual thought before the first migration runs.

What the boring default already buys you

Plain auto-increment integers, the default in MySQL and Postgres, have real virtues. They're small: 4 or 8 bytes in the key, in every foreign key, in every join. Inserts always land at the right edge of the index, so pages stay hot and filling stays efficient. Reading order 48152 in a support ticket is easier than reading a 26-character identifier. For an internal admin tool, that's genuinely the whole story.

What those small integers leak

The same sequence is also public information about your business. An endpoint like /invoices/12844 tells anyone who samples it monthly exactly how fast you're growing. Sequential IDs also make enumeration trivial, which means authorization checks stop being defense-in-depth and become the only thing between a stranger and another customer's records.

And when two systems need to merge, both of which numbered their orders 1 through 50000, every ID collides and the merge becomes a remapping project.

What UUIDs fix, and what they cost

UUIDs let any process mint a key with no coordination: the mobile app offline, the queue worker, the second server behind the load balancer. They're safe in URLs, safe to expose, and merge cleanly.

The cost is honest too. At 16 bytes they bulk up the table, the primary key index, and every foreign key column. Random UUIDv4 inserts land all over the index, splattering writes across pages, which measurably hurts on large tables.

UUIDv7 fixes the ordering problem: timestamp-prefixed, so new IDs sort after old ones and index inserts stay append-ish. Laravel 11 and newer generate v7 by default with HasUuids. ULIDs (26 characters, also time-ordered) via HasUlids are the shorter, more copyable alternative.

The shape we ship most often

For products with a public API or customer-facing URLs, the hybrid beats both purist options:

$table->id();                          // internal: fast, small
$table->uuid('public_id')->unique();   // external: exposed in URLs

Routes bind on public_id, the integer never leaves the server, and the auth layer no longer leans on IDs being unguessable. One extra column and one index, and you get the insert performance of integers with the embeddability of UUIDs. Stripe's identifiers (cus_, ch_) follow the same idea.

When a single column is the right call

  • Purely internal tools, no shared URLs, no integrations: auto-increment, no ceremony.
  • Public SaaS, mobile clients, third-party integrations: UUIDv7 as the primary key, accepting the storage cost.
  • Anything customer-facing where you're not sure: hybrid. It's the choice that survives the requirements changing.

The expensive part is switching later

Teams rarely regret having a public identifier. They regularly regret not having one, because adding it later means backfilling a live table, updating every URL someone has bookmarked, and keeping both identifiers dual-served through a deprecation window. Ten minutes on day one buys you out of that whole project.