B-tree key order: UUIDv4 versus UUIDv7
Run a real systemNeeds Docker, Python
Problem
A B-tree keeps keys in order, so where a new key goes is decided by its value. A key that always grows (a sequence, a time-ordered UUIDv7) always lands on the rightmost leaf: pages fill to the top, split once, and are never touched again. A random key (UUIDv4) lands on a random leaf. Every leaf is equally likely to be hit, so each one is split when full and then sits half empty until random inserts fill it again; in steady state leaves average about 69% full (ln 2), the index is larger, and the working set of pages being written is the whole index rather than one page.
In PostgreSQL, create two tables with the same payload (200 bytes per row). One uses a random UUID primary key, the other a time-ordered key (here an auto-increment integer). Insert 5 million rows into each, then compare the index size (pg_relation_size), the insert time, the leaf fill and the bytes of write-ahead log written.
The question. How much larger and emptier is the random-key index, and how much more does inserting into it cost?
Predict
Expect the random-key index to be about 1.4 to 1.5 times larger (pages about 69% full) and the insert to need many more page writes.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
b-tree-key-order-uuidv4-versus.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """B-tree key order: random UUIDs against a time-ordered key. Two PostgreSQL tables with the same 200-byte payload, one keyed by a random UUID (v4), one by an increasing bigint (time-ordered, like UUIDv7). 5 million rows each: insert time, primary-key index size, leaf-page fill, and WAL bytes written. Needs the PostgreSQL service from the compose file (port 55490). """ import argparse, json, time import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55490/postgres", help="Connection string.") p.add_argument("--rows", type=int, default=5_000_000, help="Rows per table.") p.add_argument("--batch", type=int, default=50_000, help="Rows per INSERT statement (so keys arrive in many batches).") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() con = psycopg.connect(args.dsn, autocommit=True) con.execute("CREATE EXTENSION IF NOT EXISTS pgstattuple") rows = [] for name, ddl, key in (("random UUID (v4)", "id uuid PRIMARY KEY", "gen_random_uuid()"), ("time-ordered bigint", "id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY", None)): tbl = "t_uuid" if key else "t_seq" con.execute(f"DROP TABLE IF EXISTS {tbl}"); con.execute(f"CREATE TABLE {tbl} ({ddl}, payload text)") con.execute("CHECKPOINT") wal0 = con.execute("SELECT pg_current_wal_lsn()").fetchone()[0] t = time.perf_counter() for _ in range(args.rows // args.batch): if key: con.execute(f"INSERT INTO {tbl} SELECT {key}, repeat('x', 200) FROM generate_series(1, {args.batch})") else: con.execute(f"INSERT INTO {tbl} (payload) SELECT repeat('x', 200) FROM generate_series(1, {args.batch})") dt = time.perf_counter() - t wal = con.execute("SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), %s)", (wal0,)).fetchone()[0] idx = con.execute(f"SELECT pg_relation_size('{tbl}_pkey')").fetchone()[0] fill = con.execute(f"SELECT avg_leaf_density FROM pgstatindex('{tbl}_pkey')").fetchone()[0] rows.append({"key": name, "insert_s": dt, "index_mb": idx / 1e6, "leaf_fill_pct": float(fill), "wal_mb": float(wal) / 1e6}) print(f"{name:<22} insert {dt:6.1f} s index {idx / 1e6:7.1f} MB leaf pages {float(fill):4.1f}% full WAL {float(wal) / 1e6:8.1f} MB") ratio = rows[0]["index_mb"] / rows[1]["index_mb"] print(f"random-key index / ordered-key index = {ratio:.2f}x (a uuid key is also twice the width of a bigint)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"keys": rows, "summary": [{"index_ratio": ratio, "wal_ratio": rows[0]["wal_mb"] / rows[1]["wal_mb"], "time_ratio": rows[0]["insert_s"] / rows[1]["insert_s"]}]}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
b-tree-key-order-uuidv4-versus.compose.yml # PostgreSQL for the B-tree key-order lab. services: postgres: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab ports: ["55490:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f b-tree-key-order-uuidv4-versus.compose.yml up -d --wait uv run b-tree-key-order-uuidv4-versus.py uv run b-tree-key-order-uuidv4-versus.py --help # every knob docker compose -f b-tree-key-order-uuidv4-versus.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55490/postgres | Connection string. |
| --rows5,000,000 | Rows per table. |
| --batch50,000 | Rows per INSERT statement (so keys arrive in many batches). |
Our run
Data table
| Primary key | Index size (MB) |
|---|---|
| — | 199 |
| — | 112 |
Two tables, same payload, 5 million rows
| Key | Insert | Index (MB) | Leaf fill | WAL (MB) |
|---|---|---|---|---|
| random UUID (v4) | 20.9 s | 199 | 71.3% | 2,100 |
| time-ordered bigint | 8.23 s | 112 | 90.1% | 1,710 |
random UUID (v4) insert 20.9 s index 199.5 MB leaf pages 71.3% full WAL 2095.9 MB
time-ordered bigint insert 8.2 s index 112.3 MB leaf pages 90.1% full WAL 1711.4 MB
random-key index / ordered-key index = 1.78x (a uuid key is also twice the width of a bigint)What we found
The random-UUID index came to 199 MB with leaves 71.3% full; the ordered-key index to 112 MB at 90.1%. The size ratio, 1.78×, is larger than the expected 1.4 to 1.5 because the comparison here also changes key width: a UUID is 16 bytes and a bigint 8, so part of the gap is width, not fill. The fill itself matched the mechanism: close to ln 2 for random inserts, close to PostgreSQL's 90% leaf fillfactor for appends. The random-key insert took 2.53× as long and wrote 1.22× as much WAL.
Read it
The cost of a random key is not one number. It is a larger index, so fewer of its pages fit in memory; a write set that covers every leaf, so every checkpoint flushes pages all over the index; and more WAL, because each page touched for the first time after a checkpoint is logged in full. A time-ordered key concentrates all of that on the rightmost page.
That concentration is its own cost under concurrency: every inserting session wants the same leaf, which shows up as a hot-page wait. UUIDv7 keeps the global uniqueness of a UUID and the append pattern of a sequence.
Go further
If you insert from several threads, also watch whether the sequential key creates a hot-page wait.