PostgreSQL UUID Performance: Benchmarking Random (v4) and Time-based (v7) UUIDs
Universally Unique Identifiers (UUIDs) are 128-bit values designed to ensure uniqueness across systems, without requiring any central coordination. For UUIDv4, a sample of 3.26×10¹⁶ values has a 99.99% chance of containing no duplicates, thanks to its 122 bits of randomness [source]. This makes them ideal for use as primary keys in a database, particularly in distributed systems.
One of the most widely used UUID formats is UUIDv4, which relies entirely on random number generation. Because they don’t encode any order or time information, UUIDv4s are inherently non-sequential.
This randomness makes them excellent for ensuring uniqueness across nodes, but it also leads to poor index locality in databases like PostgreSQL, especially when used as primary keys. Each insert happens in a random location in the B-tree, which causes frequent page splits and bloated indexes over time.
To address this, the IETF proposed UUIDv7, a time-based format that embeds a millisecond-resolution Unix timestamp in the high-order bits.
This results in UUIDs that retain uniqueness while also being roughly monotonically increasing, making them far more index-friendly. UUIDv7 retains global uniqueness while offering better performance characteristics for time-ordered inserts and queries in databases like PostgreSQL.
But does UUIDv7 actually perform better in practice, particularly in PostgreSQL?
In this article, we'll benchmark UUIDv4 and UUIDv7 in PostgreSQL by comparing their insert speeds, index sizes, and query performance. We'll dig into how the structure of UUIDs impacts B-tree behavior, and whether switching to UUIDv7 is worth it for modern applications.
UUID Versions Explained:
UUIDs are typically represented as 36-character hexadecimal strings with hyphens. Despite their compact string appearance, they carry structured meaning depending on the version.
A UUID is split into five parts:
UUIDv4: Random
UUIDv4 is the most commonly used version. It sets only two fields:
Everything else is pure randomness. This ensures high entropy but results in non-sequential values.
Downside: Poor locality in B-tree indexes due to randomness.
UUIDv7: Time-based
UUIDv7 was introduced to improve temporal ordering and index performance. It uses the high bits to encode a Unix timestamp in milliseconds, while the remaining bits are random to preserve uniqueness.
Bit layout of UUIDv7:
Benefit: Maintains insertion order in databases, improving index locality and reducing write amplification.
Why Key Locality Matters in PostgreSQL:
Choosing the right primary key doesn’t just influence how your data is uniquely identified, it also has a profound impact on how efficiently that data is stored, indexed, and retrieved. One often-overlooked consideration is how your key choice affects data locality and write performance within the database engine.
PostgreSQL, like many relational databases, uses B-tree indexes to organize and access primary key values. These indexes store keys in sorted order, making them highly efficient for range queries and lookups, but also sensitive to the order in which keys are inserted.
How B-tree Indexes Work in PostgreSQL:
A B-tree in PostgreSQL is made up of fixed-size pages, usually 8 KB in size, that hold sorted key-value entries. When a new row is inserted into a table with a B-tree-indexed primary key, PostgreSQL traverses the tree to find the appropriate page where the new key belongs. If the target page has space, the new entry is inserted directly. But if the page is full, PostgreSQL splits it into two pages: one holding the lower half of the entries, and the other the upper half. The tree is then updated to reflect this structural change.
Page splits are not just computationally expensive, but they also result in additional I/O, increased write amplification, and potential index bloat. Over time, a heavily fragmented index becomes slower to write to and less efficient to read from.
Why Random UUIDs (v4) Hurt Performance:
UUIDv4 is popular for primary keys because it provides excellent randomness and extremely low collision risk. However, this randomness comes at a cost.
Because UUIDv4 values are entirely random, new entries are inserted into arbitrary positions in the B-tree. PostgreSQL cannot make any assumptions about where the next UUID will fall in the keyspace and hence every insert effectively becomes a random-access write. This behaviour leads to frequent page splits as new keys collide with existing ones across the tree.
Over time, this causes the index to bloat, increases write amplification, and reduces the effectiveness of caching, since recently used index pages are unlikely to be reused soon. Additionally, queries that rely on ordered traversal, such as ORDER BY id DESC or cursor-based pagination using WHERE id > ? suffer from poor performance because the data is scattered non-sequentially throughout the tree.
Why UUIDv7 Fixes This:
UUIDv7 was introduced to solve this very problem. It embeds a 48-bit Unix timestamp (in milliseconds) into the most significant bits of the UUID, resulting in values that are roughly time-ordered.
This means that UUIDv7 values are monotonically increasing over time, which dramatically improves index locality. As new records are inserted, their UUIDv7 keys tend to fall at the end of the B-tree. This significantly reduces the likelihood of page splits, minimizes fragmentation, and allows PostgreSQL to optimize for sequential writes.
Because of this time-based structure, UUIDv7 provides behavior similar to that of auto-incrementing integers, but without sacrificing the global uniqueness and decentralization benefits that UUIDs offer. The timestamp ensures order, while the random bits in the lower portion of the UUID maintain uniqueness even across distributed systems.
In practice, systems using UUIDv7 as a primary key observe lower write amplification, reduced disk I/O, and faster performance for queries that involve ordered traversal or cursor-based pagination. The B-tree remains compact and more predictable, which also improves performance under high write loads or concurrent inserts.
While UUIDv4 excels in uniqueness, UUIDv7 offers a practical compromise, retaining uniqueness while gaining the efficiency of ordered inserts.
In summary:
Experiment Setup:
To evaluate the practical impact of UUIDv4 vs UUIDv7 on PostgreSQL performance, we will run benchmarks using identical table structures, data and insertion logic. The only variable change will be the UUID version used for the primary key.
a. Database Configuration:
I will be using PostgreSQL 16 for the benchmark, hosted locally inside a docker container on a system with:
I will be using pgAdmin 4 to run any SQL queries against the database.
b. Table Schema:
Two tables are created with the exact same structure. Only the key generation strategy differs.
Each row will have a UUID key and a small random string in the payload column to simulate realistic row sizes.
c. UUID Generation:
To eliminate any bias in benchmarking, both UUIDv4 and UUIDv7 values will be generated using the same Go script, in memory, before the insert operation starts. This allows us to isolate and measure only the time taken by the database to perform inserts.
This ensures:
We will pre-generate the full dataset (UUID + payload) in slices of structs, and measure only the database insertion time, excluding UUID and payload generation from the timing. The insertions will be performed using parameterized queries in batches (e.g. 10,000 rows per batch) using database/sql.
d. Go script responsibilities:
The Go benchmark script will:
This setup ensures we're isolating the effect of UUID key locality on B-tree index behaviour without being skewed by unrelated overhead.
The Benchmarking Script:
To isolate and accurately measure the impact of UUID version on insert and query performance, we will write a Go benchmarking script that:
a. Dependencies:
We will use the following Go packages:
b. UUID and Payload Generation:
Before benchmarking inserts, we generate all UUIDs and payloads in memory:
c. Insert Logic:
We use PostgreSQL's pq driver to perform batch inserts of size 10,000 rows:
d. Execution:
Recommended by LinkedIn
Note:
Benchmark Execution:
Before diving into raw performance numbers, I would love to demonstrate a key property of UUIDv7 - monotonicity.
Unlike UUIDv4 (which is completely random), UUIDv7 is designed to be time-ordered, embedding the current Unix timestamp (in milliseconds) into the most significant bits of the UUID. This allows for natural sortability, better index locality, and potential performance advantages for write-heavy workloads.
Here’s a set of UUIDv7 values I generated in Go, pausing for 1 millisecond between each call:
If you observe closely, the hexadecimal digits in the second segment of each UUID (after the first hyphen) are gradually increasing:
2f2d → 2f2e → 2f30 → 2f31 → … → 2f37
This confirms that UUIDv7 values preserve insertion order, which should result in fewer B-tree page splits in PostgreSQL and better index write locality - a hypothesis we will validate in the benchmarks below.
Insert Performance:
I inserted 10 million rows into each table using batched inserts (10,000 rows per batch), with UUIDs and payloads pre-generated in memory to ensure the measurement reflects only database insertion time.
Analysis:
i. UUIDv7 inserts were ~34.8% faster than UUIDv4 inserts.
ii. The performance gain is due to UUIDv7’s monotonic nature, which improves B-tree index locality:
iii. This performance improvement becomes more pronounced as the table grows and the B-tree index gets deeper.
In a high-insert workload (like logs, events, or user activity tracking), switching from UUIDv4 to UUIDv7 can yield tangible write performance benefits.
Disk Usage:
To assess how the UUID type affects storage footprint, I measured the total relation size (table + index) using:
Analysis:
i. The UUIDv7 table uses ~175 MB less disk space than UUIDv4, despite having the same number of rows and exactly same schema.
ii. This can be attributed to:
iii. UUIDv4, being completely random, causes heavier index fragmentation, leading to larger storage usage.
This highlights that UUIDv7 not only improves insert performance but is also more storage-efficient, especially at scale.
Index Size:
In addition to measuring the total disk usage, I also analyzed the disk footprint of the primary key indexes. Since both tables use a UUID PRIMARY KEY, PostgreSQL automatically creates a B-tree index on the id column.
I queried the size of the index alone using the following query:
Analysis:
i. The index built on UUIDv7 is 174 MB smaller than the one on UUIDv4.
ii. This translates to a ~22% reduction in index size.
iii. The difference is a direct result of UUIDv7's monotonic nature, which provides:
Smaller indexes improve read performance, particularly for range scans and point lookups.
They also reduce I/O pressure, making UUIDv7 a better choice for write-heavy and read-latency-sensitive workloads at scale.
Query Performance:
I measured point lookup and range scan performance for both UUIDv4 and UUIDv7 using the following queries:
Point Lookup:
Analysis:
Range scan:
Analysis:
UUIDv7 outperforms UUIDv4 in both point lookups and range scans, with lower execution times, thanks to its monotonic sequence.
The lower disk usage and faster query performance make UUIDv7 a more efficient choice for databases, especially when querying large datasets.
Practical Considerations:
While UUIDv7 clearly demonstrates performance and storage advantages, choosing it in production should still account for a few practical factors:
Pros of UUIDv7:
Caveats:
Conclusion:
This benchmark set out to answer a simple question: “Is UUIDv7 actually better than UUIDv4 in PostgreSQL?”
The results speak for themselves:
In summary:
UUIDv7 not only preserves global uniqueness but also enhances PostgreSQL performance in meaningful ways.
If you're building systems that scale, especially write-heavy ones, it's a very strong candidate.
All code used in this benchmark, including UUID generation, PostgreSQL schema, and Go benchmarking logic is available here:
Feel free to fork, run, or modify it for your own experiments!
Sources and further reading:
Yes — UUIDs are a good choice for API development when your app needs scalability, distributed ID generation, or better security. But for simpler apps, auto-increment IDs are still okay.