Skip to content
About
Main content

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 ) 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 written.

The question. How much larger and emptier is the random-key index, and how much more does inserting into it cost?


Predict

How many times larger is the random-UUID index than the time-ordered-key index?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    b-tree-key-order-uuidv4-versus.py50 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    b-tree-key-order-uuidv4-versus.compose.yml12 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

Index size by key orderApple M2 · 8 cores · 8 GB
Data table
Index size by key order
Primary keyIndex size (MB)
—199
—112
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

Two tables, same payload, 5 million rows

Two tables, same payload, 5 million rows
KeyInsertIndex (MB)Leaf fillWAL (MB)
random UUID (v4)20.9 s19971.3%2,100
time-ordered bigint8.23 s11290.1%1,710
Output · uv run b-tree-key-order-uuidv4-versus.py
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.