v5.0
live available
practices guide 08 Oct 2026

One million rows, seven primary keys, and the write ahead log each one cost

The same million rows loaded seven times into Postgres 17, changing only the key. Index size moved by 3.5x and WAL by 2x, and UUID v7 lost its whole advantage at a rate most people never check.

By Purvansh Parmar Checked 2026-10-21 Read 9 min Entries 7

Pick the primary key once and you pay for it on every insert, every index page, every byte of replication traffic and every base backup for as long as the table exists. The usual argument stops at whether random keys fragment the index. That is the small half of the bill. Loading one million identical rows into Postgres 17 seven times, changing nothing but the key, moved the index by 3.5x and the write ahead log by 2x, and the scheme most often recommended as the fix turned out to depend on a number nobody measures: how many ids you generate inside a single millisecond.

How this was checkedLoaded the identical million row payload into Postgres 17.11 seven times, changing only the primary key, and measured COPY time, heap size, index size, WAL bytes from the pg_lsn delta, and B-tree leaf density from pgstatindex. Then generated UUID v7 at three different rates to find where its ordering stops working. At ten ids per millisecond v7 indexes exactly like a counter at 90 percent density. At a thousand per millisecond it drops to 67 percent and its index comes out larger than random v4's.

What I compared on

01 bigint identity

A counter in the database, eight bytes, strictly increasing, the default nobody argues for.

Wins
It is the cheapest key on every axis at once. The 90.1 percent density is the fillfactor doing exactly what it was designed to do, because an append only key never splits a page in the middle.
Loses
The value is guessable and it leaks volume: anyone holding id 4,100,322 knows roughly how many rows you have. It also has to come from the database, which rules it out when ids are assigned before the insert.
21 MB index96 MB WAL90.1 percent leaf density

02 Snowflake style bigint

Timestamp in the high bits, machine id and a per millisecond counter in the low bits, still eight bytes.

Wins
It is the only scheme here that gets counter class numbers without the database assigning the value. Eight bytes, sortable by time, and no coordination between writers beyond a machine id.
Loses
You have to operate it: a clock that moves backwards produces duplicates, and the epoch and bit layout are decisions you are stuck with. The 9.7 percent fragmentation shows the per millisecond counter is not perfectly ordered across writers.
24 MB index98 MB WAL80.8 percent density

03 UUID v7

Sixteen bytes, millisecond timestamp in the leading bits, the rest random, stored in a native uuid column.

Wins
Below roughly one id per millisecond per writer it is a counter with none of the coordination: same density, same page behaviour, and the value is safe to expose. It is the right default for most services.
Loses
The ordering is only as fine as the timestamp. Generate more ids in a millisecond than fit on a leaf page and the random tail scatters them inside that millisecond's neighbourhood, and you have bought sixteen byte keys for v4 behaviour.
30 MB at 10 ids/ms41 MB at 1000 ids/ms90.0 vs 67.0 percent density

04 UUID v4 in a uuid column

Sixteen random bytes in the native type, the key everyone reaches for and everyone is warned about.

Wins
Nothing about it can leak and nothing has to coordinate. Against the text version of the same value it saves 35 MB of index and 63 MB of WAL per million rows, which makes the column type the easiest win on this list.
Loses
It is the slowest of the binary keys and it never improves: there is no rate, no batch size and no tuning that makes a random key fill a page. The 50 percent fragmentation is permanent.
38 MB index126 MB WAL71.6 percent density

05 ULID in a text column

Timestamp plus randomness, rendered as 26 Crockford base32 characters, usually stored as text.

Wins
It sorts lexicographically as text, so an ordinary string index gives you time ordering without a special type, and the 26 character form is case insensitive and safe in a URL.
Loses
Keeping it as text is the whole problem: 26 characters against 16 bytes, and it carries the same millisecond resolution ceiling as v7 without the native type to soften it. Stored as bytea it would be a different entry on this list.
63 MB index162 MB WAL26 characters

06 nanoid

Twenty one random URL safe characters, no timestamp, designed to be short rather than sortable.

Wins
It is the shortest value here that is still unguessable, which matters when the id ends up in a URL a human reads aloud. Five characters shorter than a ULID and fifteen shorter than a text uuid.
Loses
No ordering at all, so it has every page splitting cost of v4 plus string comparison on top, and it was the slowest load in the set. There is no version of this key that indexes well.
51 MB index146 MB WAL21 characters

07 UUID v4 in a text column

The same random uuid, stored as its 36 character hyphenated string because the ORM defaulted that way.

Wins
It is portable across databases that have no uuid type and it needs no cast anywhere, which is teh entire reason it keeps happening.
Loses
You pay 20 extra bytes per row in the heap and in every index that includes the key, for a value that has one canonical binary form. This is the one entry on the list that is simply a mistake.
73 MB index189 MB WAL36 characters for 16 bytes

Side by side

COPYPK indexWALLeaf density
bigint identity1.65 s21 MB96 MB90.1%
Snowflake bigint1.65 s24 MB98 MB80.8%
UUID v7 (10/ms)2.21 s30 MB113 MB90.0%
UUID v7 (1000/ms)2.76 s41 MB124 MB67.0%
UUID v4 (uuid)3.54 s38 MB126 MB71.6%
ULID (text)3.24 s63 MB162 MB67.5%
nanoid (text)4.06 s51 MB146 MB69.0%
UUID v4 (text)4.57 s81 MB189 MB69.9%

Verdict

Use UUID v7 in a native uuid column. Then count your ids per millisecond, because that number is the whole decision. At ten per millisecond it matched a strictly monotonic key exactly, 30 MB of index and 90 percent density, while giving you a value the application generates and can safely expose. At a thousand per millisecond it was worse than v4 on density and 8 percent worse on WAL, so the scheme is only as good as the rate you feed it. If you are already writing fast enough to break that, the honest answer is a Snowflake bigint: half the bytes, 24 MB of index, and a clock problem you can actually see.

Sources