"Database is down."
My phone was vibrating like it wanted to escape the table. The on-call engineer had already escalated. Customer support was replying to angry threads faster than anyone could read them.
And I was staring at a dashboard that looked like a heart monitor in a horror movie.
API latency that normally lived in the comfortable range was now spiking into the seconds. Timeouts were piling up. Retries were multiplying the load. The database was not "slow."
It was panicking.

Users were hammering buttons that did nothing. Checkouts were hanging. Orders were stuck in that worst state where nobody knows what happened, but everyone blames you anyway.
I didn't know it yet, but I was about to learn something humiliating.
A single line of code I had copied years ago and never questioned had been quietly turning our database into a heavier, hotter, more fragile machine.
The part that still hurts is not the stress.
It's the simplicity.
Because the line looked innocent.
It was in tutorials.
It was in sample code.
It was written like "best practice."
And I had defended it in code reviews like it was a law of nature.
Let me show you the one line that taught my database to suffer.
The Night Everything Fell Apart
We hadn't deployed anything recently.
No schema migrations.
No new indexes.
No new services.
Just normal traffic, normal growth, normal day.
Which made the incident feel supernatural.
The slow query log was full of things that made no sense.
Not heavy reports.
Not multi-join monsters.
Not huge aggregations.
The worst offender was a simple lookup by primary key.
SELECT *
FROM orders
WHERE id = '6e5ac3c0-8e3e-4ea0-9a63-5bfa0bf4f422';One row. By primary key. With an index that should make it effortless.
And yet the query was dragging.
I ran it again. Still slow.
I ran it directly in the database console. Still slow.
I checked locks. Nothing.
I checked replication lag. Not the problem.
I checked disk. It was busy, but disk is always busy during a fire. That didn't explain why a primary key lookup felt like a full table scan wearing a disguise.
So I asked Postgres to show me what it was really doing.
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE id = '6e5ac3c0-8e3e-4ea0-9a63-5bfa0bf4f422';The output hit me like a cold wave.
Index Scan using orders_pkey on orders
Buffers: shared hit=___ read=____
I/O Timings: read=____ msThe exact numbers are not the point.
The shape is the point.
A healthy index lookup should touch a small handful of pages.
Not hundreds.
Not thousands.
A handful.
So I ran the same kind of query against a table that still used an integer primary key, because it came from an older system and never got "modernized."
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM users
WHERE id = 12847593;
Index Scan using users_pkey on users
Buffers: shared hit=4
I/O Timings: read=0.___ msFour buffers.
That number is not magic. It's just what efficiency looks like.
Four buffers versus an ugly pile of page reads for the UUID table.
I stared at the screen longer than I want to admit.
Because I already knew the answer.
I just didn't want it to be true.
The One Line That Started It All
Here it is.
The most expensive line I ever wrote.
id UUID DEFAULT gen_random_uuid() PRIMARY KEYThat was the pattern.
Every table.
Every new service.
Every migration.
For years.
I had the usual reasons ready, always.
"UUIDs are modern."
"UUIDs let you generate IDs outside the database."
"UUIDs prevent collisions."
"UUIDs stop people from guessing IDs."
I said it with confidence because I had seen it everywhere.
I had learned the syntax.
I had never learned the physics.
And databases do not care about your syntax.
They care about what your choices do to the shape of data on disk and in memory.
How Tutorials Trained Me To Copy Without Understanding
I can still remember the early days.
I was building my first serious system where mistakes mattered. Real users. Real orders. Real money.
So I searched for guidance.
"Best practices for primary keys."
And the internet handed me a chorus.
Use UUIDs.
Use UUIDs.
Use UUIDs.
Most of the examples looked like this.
# The "Modern" Pattern You See Everywhere
import uuid
from sqlalchemy import Column, String
from sqlalchemy.dialects.postgresql import UUID
class Order(Base):
__tablename__ = "orders"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
status = Column(String)Or like this in Rails.
create_table :orders, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :status, null: false
t.timestamps
endIt felt clean. It felt professional.
So I copied it.
Then I copied it again.
And again.
Then the schema grew.
Then the traffic grew.
Then the indexes grew.
And the cost grew in the background, silently, like interest.
Tutorials taught me how to build a table.
They did not teach me how a B-Tree index behaves when you feed it randomness for years.
The Problem Is Not UUIDs
The problem is what we chose.
UUID version 4 style randomness as a primary key is the root of the pain.
Because the primary key index is a B-Tree.
And B-Trees are happiest when inserts have locality.
Locality means new keys land near each other.
Locality means your pages stay dense.
Locality means the cache stays warm.
Randomness destroys locality.
And when locality dies, your database starts doing expensive work just to stay upright.
The Library Analogy That Made It Click In My Head
Forget databases for a moment.
Imagine a library that shelves books by their ID.
Sequential Integer Shelving
A new book arrives, number 1005.
Where does it go?
Right after 1004.
The librarian walks to the end of the shelf and places it.
Simple.
Predictable.
Fast.
Shelf A Shelf B Shelf C
[1 .. 100] [101 .. 200] [201 .. 300]
^
New books mostly go hereRandom UUID Shelving
Now imagine book IDs are random.
A new book arrives with an ID that belongs somewhere in the middle.
But the middle shelf is full.
So the librarian has to split the shelf, move books, update the catalog, and then place the new book.
And this keeps happening. Everywhere.
All the time.
Shelf A Shelf B Shelf C
[0a..3f..] [40..7f..] [80..cf..]
FULL FULL FULL
New Book Arrives: "5e.."
Belongs In Shelf B
Shelf B Is Full
Shelf Must Be Split
Books Must Be Moved
Catalog Must Be Updated
In database language, that is page splits and index fragmentation.
In human language, that is wasted movement.
Wasted movement becomes latency.
Latency becomes incidents.
What Is Really Happening Inside The Index
A B-Tree index is built from pages.
Pages are where the data lives.
Pages are what the cache stores.
Pages are what the disk reads.
If you remember one thing from this article, remember this.
Your database does not "read a row."
It reads pages.
It loads pages into memory.
It walks through pages to find your entry.
So when your index needs more pages to represent the same amount of data, your queries get heavier.
Even if you are selecting one row.
Even if you are "using the index."
Even if the query looks clean.
Here is what ordered inserts look like.
Root
|
------------------------
| | |
Leaf Leaf Leaf
[... ] [... ] [1000,1001,1002,...]
^
New keys mostly land hereHere is what random inserts look like.
Root
|
------------------------
| | |
Leaf Leaf Leaf
[... ] [... ] [... ]
^ ^ ^
New keys land everywhere across the tree
Page splits happen everywhere
Pages become less dense
When you do this for long enough, the index becomes bigger than it needs to be.
Bigger index means more pages.
More pages means lower cache efficiency.
Lower cache efficiency means more reads.
More reads means slower queries.
Slower queries means more retries.
More retries means more load.
And the loop feeds itself.
The Moment The Buffer Cache Stopped Saving Us
At first, we got away with it.
Because memory is forgiving.
When the working set fits in cache, even inefficient structures can feel fast.
But a growing system eventually hits the point where the primary key index no longer fits comfortably in memory.
Then things change.
Not instantly.
Gradually.
More cache misses.
More churn.
More random reads.
Less predictability.
And suddenly your latency stops being a straight line and starts being a storm.
That was the day our system stopped feeling stable.
And that was the day this mistake stopped being theoretical.
The Proof That Turned My Doubt Into Certainty
After the incident, I needed proof I could trust.
Not opinions.
Not debates.
Numbers tied to reality.
So I built a small benchmark that matched the pattern we were living through.
Two tables.
Same payload.
Different primary key.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE records_int (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload TEXT NOT NULL
);
CREATE TABLE records_uuid (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payload TEXT NOT NULL
);Load data.
INSERT INTO records_int (payload)
SELECT repeat('x', 80)
FROM generate_series(1, 2000000);
INSERT INTO records_uuid (payload)
SELECT repeat('x', 80)
FROM generate_series(1, 2000000);Now warm the cache, then run lookups.
EXPLAIN (ANALYZE, BUFFERS)
SELECT payload
FROM records_int
WHERE id = 1500000;
EXPLAIN (ANALYZE, BUFFERS)
SELECT payload
FROM records_uuid
WHERE id = gen_random_uuid();
You do not need to worship exact values.
You need to watch the pattern.
The integer lookup tends to touch a small number of buffers.
The random UUID lookup tends to touch far more buffers over time, especially as the index grows and fragments.
And when your workload does this thousands of times per second, the gap becomes a bill you pay every minute.
This is why arguments like "it's only a few milliseconds" can be dangerously misleading.
A few milliseconds multiplied by a high volume becomes a new baseline.
A new baseline becomes scaling.
Scaling becomes money.
And money becomes pressure.
The Human Cost Was Worse Than The Metrics
While I was buried in query plans, our support lead was replying to customers with a calm voice and a tired body.
One message stuck with me.
Not because it was angry.
Because it was quiet.
A small business owner wrote that our downtime blocked a real workflow. Orders were delayed. Inventory could not be confirmed. A busy window was lost.
No threats.
No rage.
Just a simple note and then a cancellation a few days later.
You do not see that in the database logs.
You see it weeks later in churn, in reputation, in the slow erosion of trust.
We did not lose trust because we chose UUIDs.
We lost trust because I chose a pattern without understanding how it behaves under real load.
And the database made sure we paid for that ignorance.

The Security Myth I Repeated Like A Prayer
I used to say UUIDs are secure.
What I meant was hard to guess.
Hard to guess is not security.
Security is access control.
Security is authorization.
Security is enforcing who can see what.
If your system relies on "nobody can guess this ID" as protection, you are already exposed.
And here is the painful irony.
Many teams choose UUID v4 because they want public-facing IDs that do not look sequential.
They want to hide scale.
They want to avoid easy enumeration.
Those are valid goals.
But making your primary key random is not the only way to reach them.
You can keep internal keys fast and still expose an external identifier.
The Fix That Changed Everything
The fix looked boring.
Which is exactly why it worked.
For new tables, I stopped using random UUIDs as primary keys.
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Then I added a separate external identifier when I needed one.
ALTER TABLE orders
ADD COLUMN public_id UUID NOT NULL DEFAULT gen_random_uuid();
CREATE UNIQUE INDEX orders_public_id_uniq
ON orders(public_id);
Now internal joins and lookups use the integer key.
Fast.
Dense.
Cache-friendly.
And external APIs return the public identifier.
Opaque.
Unpredictable.
No easy enumeration.
Same goals, without poisoning the primary key index.
This one change reduced load in a way you can feel.
Not just graphs.
The whole system became calmer.
Because the database stopped fighting randomness at its core.
What About Existing Tables That Already Use UUID Primary Keys
This is where reality bites.
Once UUID v4 has been used for years, the index is already shaped by randomness.
You cannot wave a configuration flag and undo it.
Tuning helps, but tuning does not change the fact that random inserts land everywhere.
What you can do is stop making it worse going forward.
If you truly need UUIDs generated outside the database, the best direction is moving toward time-ordered identifiers rather than fully random ones.
The key idea is simple.
If new values are roughly ordered, inserts land closer to the end of the index more often.
That improves locality.
It reduces page splits.
It keeps pages denser.
It lowers the cost of growth.
Even if you cannot rewrite the past, you can stop feeding the chaos.
Why This Mistake Survives For So Long
Because it looks like cleanliness.
Because it looks like modern engineering.
Because it is taught as a default pattern.
And because the first phase feels fine.
A small database forgives you.
A low traffic app forgives you.
A new index forgives you.
Then success arrives.
And the same pattern that felt harmless becomes expensive.
This is why I call it the most expensive one-line mistake.
It does not hurt you when you are learning.
It hurts you when you are winning.
The Question That Changed How I Build Systems
Now, whenever my fingers want to type UUID out of habit, I pause and ask one question.
Do I need the identifier before I talk to the database?
If the answer is no, an ordered integer primary key is usually the simplest and strongest option for an OLTP database.
If the answer is yes, I still avoid randomness as the default choice, and I think hard about locality and index behavior.
That one question has saved me more pain than any fancy framework.
Because it forces me to design for reality, not fashion.
The Warning I Wish Someone Had Given Me
Somewhere right now, someone is starting a serious project.
They are searching for best practices.
They are reading sample code.
They are about to paste the same line I pasted years ago.
id UUID DEFAULT gen_random_uuid() PRIMARY KEYIt looks clean.
It looks safe.
It looks professional.
And it will work.
It will work for a while.
Then it will scale.
And when it scales, it will demand payment in the form of cache churn, fragmented indexes, and unpredictable latency.
I cannot warn the person I used to be.
But maybe I can warn the person who is about to repeat my mistake.
If you are using UUID v4 as a primary key today, I am not judging you.
I was you.
I defended it too.
I want to know one thing.
Have you measured what it does to buffers and page behavior under your real dataset size, or are you trusting the feeling that it is probably fine?
Tell me what you saw.
If you have lived through this kind of incident, tell me how you fixed it.
And if you have never measured it, this is your sign to stop trusting tutorials and start trusting the database.
Because the database always tells the truth.
It just speaks in pages.