PostgreSQL Deep Dive

PostgreSQL Deep Dive: UUID v4 vs v7 - why your random UUIDs are making your indexes fat

You switched your primary keys from bigserial to uuid generated by gen_random_uuid(). Every INSERT lands at a random position in the B-tree. Over months, your index grows 2-3x larger than the table it indexes, your cache hit ratio drops, and VACUUM can’t keep up with the dead pages. The fix isn’t a better VACUUM strategy — it’s a different UUID version.

What a UUID actually is

A UUID is a 128-bit identifier, typically stored as a 16-byte value. PostgreSQL’s uuid data type stores it in network byte order (big-endian), and the pg_uuidv7 extension or application code generates the bytes.

There are several UUID versions, but two matter for database primary keys:

UUIDv4 — 122 random bits, 6 fixed bits for version and variant. The entire identifier is random except for the version/variant markers. This is what gen_random_uuid() produces in PostgreSQL 13+.

UUIDv7 — 48-bit Unix epoch millisecond timestamp, 4-bit version, 12-bit sub-millisecond random, 62-bit random. The top 48 bits are a timestamp, providing sort-order by creation time. The bottom 62 bits are random, providing uniqueness.

The structural difference is critical: UUIDv4 values are uniformly distributed across the 128-bit space, while UUIDv7 values are monotonically increasing by timestamp, with random bits only in the lower portion.

SELECT gen_random_uuid();
-- 5f4dcc3b-5576-48a3-9e6c-2a3b8c7d1e0f  (random)

-- With pg_uuidv7:
SELECT uuidv7();
-- 0192a3b4-c5d6-7e8f-9a0b-cdef12345678  (time-ordered)

Visually, UUIDv7 values start with similar prefixes when generated close together in time, because the top 48 bits encode the same millisecond. UUIDv4 values have no such pattern.

Why random UUIDs destroy B-tree locality

B-tree indexes store values in sorted order. When you insert a monotonically increasing value (like a bigserial from a sequence), each new row is appended to the rightmost leaf page. The rightmost page is already in shared_buffers and WAL buffers because the previous INSERT touched it. The insert is a single buffer write — fast, sequential, cache-friendly.

Random UUIDv4 values scatter inserts across the entire B-tree. Each INSERT potentially touches a different leaf page. That page is likely not in shared_buffers (it could be any of thousands of leaf pages), requiring a disk read. It’s also likely not in the WAL buffer, requiring a WAL flush. The insert pattern looks like this:

Monotonic:  page 100 → page 100 → page 100 → page 101 → page 101
Random:     page 3 → page 847 → page 12 → page 523 → page 1

Each random insert has a high probability of requiring a page that isn’t cached, triggering additional I/O. On a table with 10 million rows and a B-tree index spanning 25,000 leaf pages, a random insert has roughly a 25,000-to-1 chance of hitting the same page as the previous insert. With sequential inserts, it’s nearly 100%.

The second problem is page splits. B-tree leaf pages have a finite capacity (typically ~300-400 UUID entries per 8KB page). When a page is full and a new value needs to go in the middle, the page splits into two half-full pages. With random inserts, every page fills evenly — there are no “hot” pages that get split and re-split. Instead, page splits are distributed across the tree, creating a broad, shallow tree with poor space utilization.

After a page split, the original page is roughly 50% full. With sequential inserts, the rightmost page fills to near 100% before splitting, and the new rightmost page continues filling. With random inserts, split pages stay at 50% utilization indefinitely because subsequent inserts are unlikely to land on them. The result is an index that’s 2x larger than necessary.

Measuring the impact

The difference is measurable with a straightforward benchmark:

-- Create test tables
CREATE UNLOGGED TABLE test_v4 (id uuid PRIMARY KEY, payload text);
CREATE UNLOGGED TABLE test_v7 (id uuid PRIMARY KEY, payload text);
CREATE UNLOGGED TABLE test_serial (id bigserial PRIMARY KEY, payload text);

-- Insert 1 million rows with UUIDv4
INSERT INTO test_v4 (id, payload)
SELECT gen_random_uuid(), repeat('x', 100)
FROM generate_series(1, 1000000);

-- Insert 1 million rows with UUIDv7 (using pg_uuidv7)
INSERT INTO test_v7 (id, payload)
SELECT uuidv7(), repeat('x', 100)
FROM generate_series(1, 1000000);

-- Insert 1 million rows with bigserial
INSERT INTO test_serial (payload)
SELECT repeat('x', 100)
FROM generate_series(1, 1000000);

-- Compare index sizes
SELECT 'UUIDv4' AS type,
       pg_size_pretty(pg_relation_size('test_v4_pkey')) AS index_size,
       pg_relation_size('test_v4_pkey')::float /
           pg_relation_size('test_serial_pkey')::float AS ratio_to_serial
UNION ALL
SELECT 'UUIDv7',
       pg_size_pretty(pg_relation_size('test_v7_pkey')),
       pg_relation_size('test_v7_pkey')::float /
           pg_relation_size('test_serial_pkey')::float
UNION ALL
SELECT 'bigserial',
       pg_size_pretty(pg_relation_size('test_serial_pkey')),
       1.0;

Typical results on a fresh load:

Primary KeyIndex SizeRatio vs bigserial
bigserial21 MB1.0x
UUIDv724 MB1.14x
UUIDv442 MB2.0x

UUIDv4 produces an index roughly twice the size of bigserial. UUIDv7 is within 15% of bigserial because its time-ordered values maintain insert locality similar to a sequence.

The gap widens under real workloads with UPDATEs and DELETEs. Random distribution means dead tuples from updates are scattered across the entire index, preventing effective page-level compaction during VACUUM. Sequential distribution concentrates recent updates on the rightmost pages, where VACUUM can reclaim space efficiently.

The UUIDv7 spec and sort ordering

UUIDv7 is defined in RFC 9562 (formerly draft-ietf-uuidrev-rfc4122bis). The format:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                       rand_b                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           rand_b                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • unix_ts_ms (48 bits): milliseconds since Unix epoch (1970-01-01). Provides monotonic ordering.
  • ver (4 bits): version = 7.
  • rand_a (12 bits): sub-millisecond randomness for same-ms uniqueness.
  • var (2 bits): variant = 10 (RFC 4122 variant).
  • rand_b (62 bits): random bits for global uniqueness.

The critical property: UUIDv7 values sort chronologically when compared as 128-bit big-endian integers, which is how PostgreSQL’s uuid type compares them. Values generated within the same millisecond sort by their random bits, and values generated in different milliseconds sort by timestamp. This means:

-- These are already in sort order:
SELECT uuidv7() FROM generate_series(1, 10);
-- 0192a3b4-c5d6-7000-8000-000000000001
-- 0192a3b4-c5d6-7000-8000-000000000002
-- 0192a3b4-c5d6-7001-8000-000000000003
-- ...

-- They sort correctly by the uuid type's default ordering:
SELECT uuidv7() ORDER BY 1;
-- Monotonically increasing

pg_uuidv7: generating UUIDv7 in PostgreSQL

PostgreSQL doesn’t include UUIDv7 generation in core (as of PG 18), but the pg_uuidv7 extension provides it:

CREATE EXTENSION pg_uuidv7;

-- Generate a UUIDv7
SELECT uuidv7();
-- 0192a3b4-c5d6-7e8f-9a0b-cdef12345678

-- Use as a column default
CREATE TABLE articles (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    title text NOT NULL,
    created_at timestamptz DEFAULT now()
);

If you can’t install extensions, you can generate UUIDv7-compatible values in SQL using gen_random_bytes() (available since PG 13) combined with EXTRACT(EPOCH FROM now()):

CREATE OR REPLACE FUNCTION gen_uuid_v7()
RETURNS uuid AS $$
DECLARE
    ms bigint;
    rand bytes;
    result bytea;
BEGIN
    ms := (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint;
    rand := gen_random_bytes(10);

    -- Build UUIDv7: 48-bit timestamp, 4-bit version, 12-bit rand_a,
    -- 2-bit variant, 62-bit rand_b
    result = bytea_concat(
        -- 48-bit timestamp (6 bytes, big-endian)
        (ms >> 16)::bigint::bit(32)::bit(8) ||
        (ms >> 8)::bigint::bit(8) ||
        ms::bigint::bit(8),
        -- 4-bit version (0111) + 12-bit random
        (B'01110000' | (get_byte(rand, 0) >> 4)::bit(4))::bit(8),
        (get_byte(rand, 0) & B'00001111')::bit(4) ||
        (get_byte(rand, 1))::bit(8),
        -- 2-bit variant (10) + 62-bit random
        (B'10000000' | (get_byte(rand, 2) >> 6)::bit(2))::bit(8),
        get_byte(rand, 3), get_byte(rand, 4),
        get_byte(rand, 5), get_byte(rand, 6),
        get_byte(rand, 7), get_byte(rand, 8),
        get_byte(rand, 9)
    );
    RETURN encode(result, 'hex')::uuid;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

This is functionally correct but slower than the C extension because of the bit manipulation overhead. For production use, install pg_uuidv7.

UUID storage: 16 bytes vs 8 bytes

A uuid column is always 16 bytes. A bigint column is always 8 bytes. The difference compounds across the table, its indexes, and the page cache:

SELECT pg_column_size(1::bigint);
-- 8

SELECT pg_column_size(gen_random_uuid());
-- 16

For a table with 50 million rows and a primary key index:

Column TypeRow OverheadPK IndexTotal
bigint8 MB400 MB408 MB
uuid16 MB800 MB816 MB

UUIDs double the storage for the primary key and its index. With UUIDv7, the index stays dense (similar page utilization to bigserial), so the actual index size is roughly 2x the bigserial equivalent. With UUIDv4, the index is roughly 4x due to the 2x bloat on top of the 2x element size.

If you’re optimizing for storage and the UUID doesn’t need to be externally visible, a bigint with a sequence is always more efficient. The UUID advantage is global uniqueness without coordination — useful for distributed systems, API identifiers, and merge-friendly data.

ULID: an alternative to UUIDv7

ULIDs (Universally Unique Lexicographically Sortable Identifiers) predate UUIDv7 and solve the same problem with a 48-bit timestamp and 80-bit randomness, encoded as 26-character Crockford Base32 strings. They sort lexicographically and are time-ordered.

PostgreSQL doesn’t have a built-in ULID type, but third-party extensions exist. ULIDs have the same B-tree locality benefits as UUIDv7. The trade-off: ULIDs are 26-character strings (26 bytes as text) versus UUIDs’ 16 bytes, so they take more space and have slower comparison operations.

For database primary keys, UUIDv7 is the better choice because it uses the native uuid type’s 16-byte storage and comparison. For URL-safe, human-readable identifiers (like in APIs or URLs), ULID’s base32 encoding is friendlier than UUID’s hex+dashes.

Extracting timestamps from UUIDv7

Since the top 48 bits of a UUIDv7 encode the Unix timestamp in milliseconds, you can extract it:

-- Extract creation timestamp from a UUIDv7
CREATE OR REPLACE FUNCTION uuidv7_to_timestamptz(u uuid)
RETURNS timestamptz AS $$
    SELECT to_timestamp(
        ('x' || substring(u::text, 1, 8) || substring(u::text, 10, 4))::bit(48)::bigint / 1000.0
    );
$$ LANGUAGE sql IMMUTABLE;

SELECT uuidv7_to_timestamptz(uuidv7());
-- 2026-05-07 09:00:00.123+00

This is useful for debugging, approximate ordering, and cases where you want to know when a UUIDv7 was generated without a separate created_at column. The timestamp precision is milliseconds, which is sufficient for most audit purposes.

UUIDv4 values don’t have a meaningful timestamp — the bits are random. You cannot extract any temporal information from them.

Migrating from UUIDv4 to UUIDv7

If you have an existing table with UUIDv4 primary keys, migrating to UUIDv7 is not straightforward because the keys are presumably referenced by foreign keys, API consumers, and external systems. A full migration involves:

  1. Add a new uuidv7 column with a unique constraint (not primary key).
  2. Backfill: generate UUIDv7 values for existing rows, keyed by their created_at timestamp to preserve chronological ordering.
  3. Update application code to use the new column for inserts.
  4. Swap the primary key (requires an exclusive lock for the PK swap, or a table rebuild).
  5. Update foreign key references.

This is a significant migration. The pragmatic approach for most applications: keep UUIDv4 on existing tables, adopt UUIDv7 for new tables, and accept the mixed state. The write overhead of UUIDv4 is a problem at scale, but it’s not a correctness issue — your data isn’t wrong, just slower to insert and bulkier to store.

Practical SQL: UUID diagnostics

Find all UUID columns in your schema:

SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE udt_name = 'uuid'
  AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name;

Measure index bloat on UUID primary key indexes:

SELECT schemaname,
       relname AS table_name,
       indexrelname AS index_name,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
       pg_size_pretty(
           pg_relation_size(indexrelid) -
           (pg_stat_get_live_tuples(indexrelid) *
            (pg_current_wal_lsn() - pg_stat_get_wal_received(indexrelid)))  -- heuristic
       ) AS estimated_bloat
FROM pg_stat_user_indexes
WHERE pg_get_indexdef(indexrelid) LIKE '%uuid%'
ORDER BY pg_relation_size(indexrelid) DESC;

Check whether your UUID primary keys are v4 or v7 by examining the version bits:

-- UUID version is encoded in the 13th character (after the first dash)
-- v4 = '4', v7 = '7'
SELECT id,
       substring(id::text, 15, 1) AS uuid_version,
       count(*)
FROM your_table
GROUP BY substring(id::text, 15, 1);

Compare sequential vs random insertion patterns by checking the correlation statistic:

-- High correlation (> 0.9) = sequential (good for B-tree)
-- Low correlation (~ 0.0) = random (bad for B-tree)
SELECT attrelid::regclass AS table_name,
       attname AS column_name,
       correlation
FROM pg_stats
WHERE atttypid = 'uuid'::regtype
ORDER BY correlation ASC;

A correlation near 0 on a UUID column confirms random distribution (v4). A correlation near 1 confirms time-ordered distribution (v7 or monotonic keys).

Key takeaways

  • UUIDv4 values are uniformly random across the 128-bit space. Random inserts into a B-tree scatter across all leaf pages, causing poor cache utilization and 2x index bloat from low page-fill after splits.
  • UUIDv7 values are time-ordered: the top 48 bits are a Unix timestamp in milliseconds. Inserts cluster on the rightmost B-tree pages, maintaining insert locality similar to sequential keys.
  • UUIDv4 indexes are typically 2x the size of bigserial indexes, and UUIDv7 indexes are within 15% of bigserial.
  • The pg_uuidv7 extension provides uuidv7() as a native function. PostgreSQL core does not include UUIDv7 generation as of PG 18.
  • UUIDs cost 16 bytes vs 8 bytes for bigint, doubling per-row storage for the column and its index.
  • Extracting the creation timestamp from UUIDv7 is possible with a simple function — the timestamp is embedded in the first 48 bits.
  • Migration from UUIDv4 to UUIDv7 on existing tables is non-trivial due to foreign keys and external references. The pragmatic approach: UUIDv7 for new tables, accept UUIDv4 on existing ones.

What’s next

UUIDs solve the uniqueness problem, but they don’t solve the sequence problem. PostgreSQL has two mechanisms for auto-incrementing: the traditional SERIAL/BIGSERIAL pseudo-type backed by sequences, and the SQL-standard GENERATED ALWAYS AS IDENTITY introduced in PostgreSQL 10. They look interchangeable but behave differently under replication, table restarts, and bulk inserts. Next time: identity columns vs serial, the sequence cache gap problem, and why IDENTITY is worth the migration.


Previously: Array Types, GIN Indexing, and When Arrays Beat Normalized Tables