UUID or bigint as the primary key: the cost on B-trees, WAL, and foreign keys Intermediate
Estimated reading time: 9 min read
Read first
Problem
Section titled “Problem”The orders table has 10 million rows with primary key id bigint. An order URL is /orders/10000231: anyone placing two orders a week apart can estimate how many orders were sold. The mobile app also wants to generate ids itself while offline.
The team proposes switching to UUIDs. order_items, payments, and shipments all have foreign keys pointing to orders.id, so they must change too.
The question: if we switch to UUIDs, what do the B-tree indexes, WAL, and foreign keys pay, and which kind of UUID costs least?
What to choose (summary first)
Section titled “What to choose (summary first)”| Situation | Choose | Condition |
|---|---|---|
| The id is used internally only | bigint GENERATED ALWAYS AS IDENTITY |
The id is never exposed |
| You do not want to leak counts via URL/API | bigint PK + a public_id uuid UNIQUE column |
Foreign keys stay bigint |
| Clients or multiple services generate ids | UUID v7 as the PK | Accept leaking the creation time |
| Neither order nor creation time may leak | UUID v4 | Accept larger indexes and WAL |
| PostgreSQL 17 needs v7 | Generate it in the application or an extension | uuidv7() exists only from PostgreSQL 18 |
Core concepts
Section titled “Core concepts”bigint identity |
UUID v4 | UUID v7 | |
|---|---|---|---|
| Key size | 8 bytes | 16 bytes | 16 bytes |
| Order on insert | Increasing (may have gaps) | Random | Nearly increasing by ms |
| What leaks | Order, count can be estimated | No order | Creation time |
- UUID v4: 122 random bits. Two ids generated back to back land anywhere in the index tree.
- UUID v7 (RFC 9562): a 48-bit Unix timestamp (ms) at the front, followed by version and random bits. An id generated later is usually larger than one generated earlier.
- Full-page image (FPI): with
full_page_writes = on(the default), the first modification of a page after a checkpoint writes the whole page into WAL.
The key size is repeated in the primary key index, in each order_id foreign key column, and in the index on that column.
How it works
Section titled “How it works”Why does v4 cost so much more than v7 at the same 16 bytes? The answer lies in which pages get touched on insert.
After a CHECKPOINT: the first modification of each page writes a full-page image (FPI) to WAL.
The state after each step:
- After a checkpoint: no leaf has an FPI in the new cycle yet.
- Increasing keys (bigint, v7): the new key is always the largest and lands in the rightmost leaf. One FPI, and later inserts reuse the page that is hot in cache.
- The rightmost leaf fills up: per the
CREATE INDEXdocs, leaves are filled to fillfactor 90% when the index grows to the right, not split in half. - UUID v4: each insert touches an arbitrary leaf, which must be in shared buffers and may produce another 8 kB FPI.
- A leaf in the middle of the tree fills up: a page split divides the keys into two pages with plenty of free space; the index bloats and adjacent leaves are no longer next to each other on disk.
So insert order matters more than key size: v7 pays the same 16 bytes as v4 but keeps bigint’s insert path.
Operational details
Section titled “Operational details”- PostgreSQL 17 only has
gen_random_uuid()(v4). PostgreSQL 18 addsuuidv7()and the aliasuuidv4(). - PG17’s
uuid_extract_timestamp()only reads UUID v1; PG18 reads v7 too. - Need v7 on PG17: generate it in the application with an RFC 9562 library, or use a third-party extension (check whether your managed service allows installing it). A hand-written SQL function like the demo works but is slow.
GENERATED ALWAYS AS IDENTITY(SQL standard) rejects user-supplied values unlessOVERRIDING SYSTEM VALUEis given.serialis just a sequence plus aDEFAULT, so manually inserting anidslips through.- Sequences have gaps: rollbacks and
INSERT ... ON CONFLICTstill consumenextval(see upsert). - More WAL means replicas and backups receive more too.
- Node.js
pgreturnsbigintas astring(see data type mistakes).
Real-world example
Section titled “Real-world example”The v7 function in the demo is a hand-written SQL function for PG17, not a built-in:
CREATE FUNCTION uuid_v7_demo() RETURNS uuid LANGUAGE sql VOLATILE AS $$ SELECT encode( substring(int8send(floor(t.ms)::bigint) FROM 3) -- unix_ts_ms || int2send((x'7000'::int | floor((t.ms - floor(t.ms)) * 4096)::int)::smallint) -- ver + rand_a || substring(uuid_send(gen_random_uuid()) FROM 9), -- var + rand_b 'hex')::uuid FROM (SELECT extract(epoch FROM clock_timestamp()) * 1000 AS ms) AS t$$;rand_a holds the sub-millisecond fraction, a method RFC 9562 allows (section 6.2). Two ids within the same microsecond can still be out of order.
docker compose -f examples/docker-compose.yml exec -T postgres \ psql -U hub -d hub -q -v rows=10000000 -v extra=500000 \ < examples/src/uuid-vs-bigint-keys/uuid-keys-demo.sqlOne run on PostgreSQL 17.11 (Docker on an Apple M5, 10 CPUs / 8 GB RAM, shared_buffers = 128MB, max_wal_size = 1GB, wal_compression = off):
| Measurement | bigint | UUID v4 | UUID v7 |
|---|---|---|---|
| Load 10 million rows | 8.1 s · 1504 MB WAL | 40.1 s · 2471 MB WAL | 38.0 s · 1644 MB WAL |
500,000 rows after CHECKPOINT |
0.7 s · 75 MB WAL | 3.4 s · 364 MB WAL | 1.5 s · 82 MB WAL |
| FPIs after checkpoint (B-tree FPIs) | 7 (10 kB) | 49,230 (272 MB) | 4 (11 kB) |
| PK index blocks read outside shared buffers | 4 | 346,791 | 3 |
| Heap / PK index | 603 MB / 225 MB | 684 MB / 410 MB | 684 MB / 316 MB |
avg_leaf_density / leaf_fragmentation |
90.09 / 0 | 69.55 / 49.73 | 90.04 / 0 |
Reading the results:
- WAL: v4 writes nearly 5 times as much as bigint; 272 MB of it is B-tree FPIs. v7 is close to bigint.
- Cache misses: v4 reads 346,791 index blocks from outside shared buffers (they may still hit the OS cache).
- Size: the v7 index is about 40% larger than bigint because of the 16-byte key; v4 adds another 30% because leaves are only about 70% full.
Generating 10 million ids alone took 23.8 seconds with the SQL v7 function and 4.5 seconds with gen_random_uuid(), so v7’s load time is mostly the cost of the demo function.
Measurements vary between runs (a previous run: v4 loaded in 33 seconds). FPI counts come from pg_stat_wal and are cluster-wide. This is a dev machine, not a benchmark.
Trade-offs
Section titled “Trade-offs”Pros
-
bigint: 8-byte key, inserts to the right, little WAL, easy to read and debug. -
UUID v7: can be generated anywhere, with an insert path nearly like bigint.
-
UUID v4: leaks neither order nor time.
-
A separate
public_idcolumn: hides counts while foreign keys stay 8 bytes.
< slot="cons"> >
-
bigint: leaks counts when exposed; the database must assign ids. -
UUID: every foreign key and index on it doubles the key size.
-
UUID v4: page splits, cache misses, and WAL surge once the table outgrows memory.
-
UUID v7: leaks creation time; on PG17 it must be generated outside the database.
Cons
Decision tree for orders:
Loading diagram…
Production checklist
Section titled “Production checklist”- Decide whether the id leaves the system (URL, API, email); if so, choose a separate
public_idor a UUID PK. - New tables use
bigint GENERATED ALWAYS AS IDENTITY, notintegerorserial. - If choosing UUIDs, prefer v7; on PG17, document where ids are generated (application or extension).
- Estimate the cost: the number of foreign key columns and indexes pointing at the table, times 8 bytes per row.
- Client-supplied ids: validate them, treat
23505as a repeated request, and do not trust the timestamp inside the id. - Track
avg_leaf_density, WAL per hour, and index size. - Changing the key type on a live table: follow expand–contract.
Common mistakes
Section titled “Common mistakes”The first mistake is changing the whole primary key just to hide counts:
Switching the PK to UUID v4 just so URLs do not leak counts
Common
CREATE TABLE orders (id uuid PRIMARY KEY DEFAULT gen_random_uuid() /* ... */);CREATE TABLE order_items (order_id uuid NOT NULL REFERENCES orders /* ... */); -- every FK is 16 bytesPrefer
ALTER TABLE orders ADD COLUMN public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE;Why
Only one UUID index is added; foreign keys stay bigint. For a large table, backfill in batches instead of one statement.
The second mistake concerns privacy:
| Misconception | Consequence | What to do |
|---|---|---|
| “Every UUID is the same 16 bytes” | v4 makes indexes and WAL much larger | Prefer v7 |
| bigint ids are contiguous, so use them as invoice numbers | Gaps from rollbacks and ON CONFLICT |
Number invoices separately |
| The timestamp in a client-generated v7 can be trusted | Client clocks are wrong, sorting goes wrong | Store created_at separately |
Self-check
Section titled “Self-check”- Why do 500,000 inserts into the v4 table produce about 49,000 FPIs while the bigint table produces only a few?
- The v7 and v4 indexes have the same 16-byte key. What makes the v4 index 30% larger?
ordershas 4 tables pointing foreign keys at it, each with 30 million rows. How many extra key bytes does switching to UUIDs add, before counting indexes?
Summary
Section titled “Summary”References
Section titled “References”- PostgreSQL 17 — UUID Functions: PG17 only has v4.
- PostgreSQL 18 — UUID Functions:
uuidv7(),uuidv4(). - CREATE INDEX, fillfactor: leaf filling when growing to the right, splits causing fragmentation.
- full_page_writes: FPIs after a checkpoint.
- Identity Columns:
ALWAYSandBY DEFAULT. - pgstattuple: the columns of
pgstatindex(). - RFC 9562: UUID v7 layout (5.7), ordering within the same ms (6.2), security (8).
- B-tree index, Storage layout: leaf pages and 8 kB pages.