How Much Storage Do UUID Primary Keys Actually Cost at Scale? (Real Numbers)
The claim that "UUIDs are bigger than integers" is true but incomplete. It tells you nothing about how that size difference propagates through indexes, foreign keys, and buffer cache pressure — which is where the real cost lives. This post quantifies that cost with reproducible measurements rather than back-of-envelope byte counts.
Baseline: Raw Key Size
| Key type | Storage size | Practical range |
|---|---|---|
SERIAL / INT |
4 bytes | ~2.1 billion values |
BIGSERIAL / BIGINT |
8 bytes | ~9.2 quintillion values |
UUID (native uuid type) |
16 bytes | 2^122 random values (v4) |
UUID stored as CHAR(36)/text |
36–37 bytes | Same value space, wrong storage type |
The type matters as much as the value: storing a UUID as text roughly doubles its footprint versus the native binary representation, with no gain in functionality. That mistake alone accounts for a large share of the "UUIDs are huge" complaints seen in practice, and it's independent of the value itself — fixing it is a pure win.
-- Avoid: text storage, ~36+ bytes, no format validation
CREATE TABLE orders_bad (
id VARCHAR(36) PRIMARY KEY
);
-- Prefer: native binary storage, 16 bytes, format-validated
CREATE TABLE orders_good (
id UUID PRIMARY KEY
);
The Multiplier Effect: Foreign Keys
The primary key column itself is rarely where the cost accumulates most. Every foreign key referencing it — and every index on that foreign key — repeats the same per-row cost.
Consider an orders table referenced by five child tables (order_items, payments, shipments, order_events, refunds) across 50 million rows:
UUID: 5 FK columns × 16 bytes × 50,000,000 rows ≈ 3.7 GB
BIGINT: 5 FK columns × 8 bytes × 50,000,000 rows ≈ 1.86 GB
Each of those foreign key columns is typically indexed as well, which applies a comparable multiplier again. The primary key's width is therefore best understood as a cost that compounds across the schema, not a cost isolated to one table.
Reproducing the Measurement in Postgres
Rather than estimating, the following builds two comparable tables and measures their actual on-disk size.
CREATE TABLE bench_uuid (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id INT NOT NULL,
payload TEXT NOT NULL DEFAULT 'x'
);
CREATE TABLE bench_bigint (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INT NOT NULL,
payload TEXT NOT NULL DEFAULT 'x'
);
INSERT INTO bench_uuid (tenant_id)
SELECT (random() * 1000)::int FROM generate_series(1, 5_000_000);
INSERT INTO bench_bigint (tenant_id)
SELECT (random() * 1000)::int FROM generate_series(1, 5_000_000);
SELECT
relname AS object,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname IN ('bench_uuid', 'bench_bigint');
Representative results (Postgres 16, 5,000,000 rows, one secondary index on tenant_id in addition to the primary key):
| Table | Table size | Index size | Total |
|---|---|---|---|
bench_bigint |
279 MB | 178 MB | 457 MB |
bench_uuid |
356 MB | 249 MB | 605 MB |
The table itself is about 28% larger and the index about 40% larger for the UUID version. The index gap exceeds the raw 2x key-size ratio would suggest, which points to a second factor beyond byte width.
Why the Index Gap Exceeds the Byte Ratio
Two effects work in opposite directions:
- Fixed per-entry B-tree overhead (tuple headers, pointers, alignment) means a wider key doesn't scale the index linearly — this narrows the gap.
- Random UUID insertion order causes more page splits and lower page-fill density than sequential integer inserts, since new values land at random points in the B-tree rather than appending at the tail — this widens the gap.
The second effect can be observed directly:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT avg_leaf_density FROM pgstatindex('bench_uuid_pkey');
SELECT avg_leaf_density FROM pgstatindex('bench_bigint_pkey');
Lower avg_leaf_density on the UUID index reflects the same number of entries spread across more, less tightly packed pages — additional disk space and buffer cache pressure that the raw 16-vs-8-byte comparison doesn't capture.
MySQL/InnoDB: A Larger Gap
InnoDB clusters each table by its primary key, and every secondary index stores the primary key value as its row pointer back into that clustered index. This makes UUID width matter more in MySQL than in Postgres.
CREATE TABLE bench_uuid (
id BINARY(16) PRIMARY KEY,
tenant_id INT NOT NULL,
payload VARCHAR(50),
INDEX idx_tenant (tenant_id)
) ENGINE=InnoDB;
CREATE TABLE bench_bigint (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
payload VARCHAR(50),
INDEX idx_tenant (tenant_id)
) ENGINE=InnoDB;
SELECT table_name, data_length, index_length
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('bench_uuid', 'bench_bigint');
idx_tenant on the UUID table carries tenant_id plus a 16-byte row pointer; on the BIGINT table, the same index carries an 8-byte pointer. Every additional secondary index repeats this difference, so schemas with several secondary indexes see the UUID cost compound faster under InnoDB than under Postgres, where secondary indexes reference a physical location rather than the primary key value.
Verifying at the Application Layer (C#)
Confirming there's no additional inflation introduced by the application layer is straightforward — a Guid is a fixed 16-byte value in .NET, matching the database's native representation:
public class BenchRow
{
public Guid Id { get; set; } = Guid.NewGuid();
public int TenantId { get; set; }
public string Payload { get; set; } = "x";
}
Console.WriteLine(Marshal.SizeOf<Guid>()); // 16
The inflation back to 36+ bytes typically enters through an ORM mapping that stores the column as varchar rather than the native uuid/BINARY(16) type, or through code that round-trips a Guid through ToString() before persistence.
Comparison at Scale: 50 Million Rows
Extrapolating the measured per-row deltas to an orders table with three foreign-keyed child tables:
| Metric | BIGINT keys | Native UUID (16-byte) | UUID as CHAR(36)/text |
|---|---|---|---|
| Primary key column | 400 MB | 800 MB | ~1.8 GB |
| PK index | ~450 MB | ~630 MB | ~1.1 GB |
| 3x FK columns | 1.2 GB | 2.4 GB | ~5.4 GB |
| 3x FK indexes | ~1.35 GB | ~1.9 GB | ~3.3 GB |
| Approximate total | ~3.4 GB | ~5.7 GB | ~11.6 GB |
The BIGINT-to-native-UUID gap is roughly 1.7x — real, but predictable and manageable. The gap to text-stored UUIDs exceeds 3x, and unlike the inherent cost of using UUIDs at all, that gap is a schema error with a straightforward fix.
Alternatives and Mitigations
| Approach | Storage impact vs. UUIDv4 | Trade-off |
|---|---|---|
| BIGINT/AUTO_INCREMENT | Smallest | No coordination-free generation, exposes row cardinality |
| Native UUID type (v4) | Baseline for this comparison | Random insert order costs index density |
| UUIDv7 | Same byte size as v4 | Time-ordered layout restores append-mostly insert pattern, narrowing the density gap without reducing byte width |
| UUID as text | ~2.25x native UUID | No advantage; a storage-layer mistake to correct |
| Composite natural key (where one exists) | Often smaller than either | Only viable when a stable, unique business key already exists |
Conclusion
A correctly typed UUID primary key costs roughly 1.5–2x the storage of a BIGINT once the key, its index, and referencing foreign keys are all accounted for — not the 4x suggested by comparing raw byte widths alone, and well short of the 3x+ seen when UUIDs are stored as text. The measurement queries above (pg_total_relation_size, pgstatindex, information_schema.tables) can be run against any existing schema to get an exact figure rather than relying on general ratios, since the actual overhead depends on row width and the number of referencing tables.
Tools and Versions
Need some UUIDs for testing? Try our tools for generating
UUIDv4
UUIDv7
ULID
NanoID
and
decoding
validating.
For a detailed comparison of UUID versions, visit our Complete Guide to UUID Versions.