For a database primary key, use UUID v7; keep UUID v4 for values that must not reveal anything. Both are 128-bit identifiers in the same uuid column type. The difference is the first 48 bits: v7 puts a Unix timestamp in milliseconds there, so new keys sort after old ones and land at the end of a B-tree index. v4 is random throughout, so every insert lands somewhere in the middle.

In our PostgreSQL test with 10 million rows, the v4 load took about 3.5 times as long as an ordered v7 load and wrote 41% more WAL. There is a catch the usual advice skips: a v7 generator that only uses the millisecond can behave almost like v4 when it produces hundreds of IDs within one millisecond. Which generator you use matters as much as the version number.

What changes inside the 128 bits

RFC 9562 ↗, the May 2024 standard that replaced RFC 4122, defines both layouts. Six bits are fixed in each: a 4-bit version and a 2-bit variant.

Field UUID v4 UUID v7
Bits 0–47 Random Unix time in milliseconds
Bits 48–51 Version 0100 (4) Version 0111 (7)
Bits 52–63 Random 12 bits: random, or extra clock precision or a counter
Bits 64–65 Variant 10 Variant 10
Bits 66–127 Random 62 bits random (or partly a counter)
Random bits 122 Up to 74
Sorts by creation time No Yes, to the millisecond

The RFC explains why v7 exists: random UUIDs “have poor database-index locality”, so inserts go “at random locations” in a B-tree, and the performance effects “can be dramatic”. It also says implementations should use v7 instead of the older time-based v1 and v6 where possible.

Reading the time out of a v7 UUID

Because the timestamp sits in plain hex at the front, anyone holding a v7 value can recover when it was made. Take 01a1c94b-d49a-77ac-bef0-c181ff98601e, generated during our test:

  1. The first 12 hex digits, 01a1c94bd49a, are the timestamp.
  2. In decimal that is 1,794,378,552,474 milliseconds since 1 January 1970.
  3. That is 2026-11-11 06:29:12.474 UTC.
  4. The 13th digit, 7, is the version.

In PostgreSQL 17 and later, uuid_extract_timestamp() ↗ does this for you and returns null for versions other than 1 and 7. The PostgreSQL documentation adds that the 48-bit field runs out in about the year 10889.

What our PostgreSQL test measured

We loaded 10 million rows into four tables, each with a single primary-key column and an integer, on PostgreSQL 16.15 (Ubuntu 24.04 build) in a 4-vCPU, 15 GB virtual machine with the default shared_buffers of 128 MB. The keys were generated first into unlogged staging tables, so the timings measure index work, not the generator. Each load was one INSERT … SELECT, with a checkpoint before it. WAL is the write-ahead log, the change record PostgreSQL writes before the data files and ships to replicas.

Key Load time WAL written Primary-key index Average leaf fill
UUID v4 (gen_random_uuid()) 52.6–54.7 s 2,096–2,097 MB 384 MB 70.7%
UUID v7, millisecond only 19.0–19.2 s 1,555 MB 369 MB 73.5%
UUID v7, same values in strict order 15.6 s 1,486 MB 301 MB 90.0%
bigint 1 to 10,000,000 16.1 s 1,324 MB 214 MB 90.1%

Ranges are two runs; single figures are one run. The 90% fill of the ordered keys is the B-tree’s default fillfactor, which PostgreSQL applies “when extending the index at the right”, per its CREATE INDEX reference ↗. Pages that fill up in the middle are split instead, and the halves stay part-empty; that is v4’s 70.7%. The bigint index is smaller because each entry carries an 8-byte key instead of 16 bytes. On your hardware the times will differ, but the order of the rows should not.

Why “millisecond only” v7 lost most of the gain

PostgreSQL 16 has no built-in v7 generator, so we used a common SQL function that writes the current millisecond over the front of a v4 value and sets the version bits. Our staging load created 10 million IDs in about 25 seconds, roughly 413 per millisecond. Within each millisecond the remaining bits are random, so the index again took random inserts inside a narrow range. That second row of the table is the result: an index almost as large as v4’s. Sorting the same values, which is what a monotonic generator would have produced, gave the third row.

To find where this starts, we built 3 million keys with a fixed number of IDs per millisecond and random bits after the timestamp, then loaded them in time order:

IDs per millisecond Average leaf fill Index size
1, 2, 10 or 50 90.0% 90 MB
100 93.4% 87 MB
200 68.5% 119 MB
300 52.0% 157 MB
1,000 67.0% 121 MB
UUID v4, for comparison 67.5% 121 MB

Up to 100 IDs per millisecond the index stayed packed; from 200 it was as loose as v4, and at 300 worse. A web app creating a few rows per second never gets near that. Bulk imports, backfills and event pipelines can.

The RFC anticipates this in section 6.2, with three ways to keep IDs in order within one millisecond: a dedicated counter, a randomly seeded counter incremented for each ID, or replacing up to 12 random bits with finer clock time. The generators differ:

  • PostgreSQL 18 uuidv7() uses the millisecond plus a sub-millisecond timestamp plus random bits, per its documentation ↗. It arrived in PostgreSQL 18.0 ↗, released September 25, 2025, with a uuidv4() alias.
  • Python 3.14 uuid.uuid7() uses a 48-bit timestamp and a 42-bit counter “to guarantee monotonicity within a millisecond”, per the Python documentation ↗. Python 3.13 and earlier have no uuid7().
  • A hand-written millisecond-only function, like the one we used on PostgreSQL 16, gives no order within a millisecond.

The RFC’s database section also notes that UUIDs generated by the database, rather than by many clients with their own clocks, give “the best UUID monotonicity”.

When UUID v4 is still the right choice

RFC 9562 says UUIDs “MUST NOT be used as security capabilities”, meaning identifiers whose possession alone grants access, and that where a UUID is used in security operations, v4 should be used. A v7 value is weaker for that job on two counts:

  • It tells the reader when the record was created. The worked example above needed no key, only a hex converter. If creation time is sensitive, such as an account sign-up date, or shows how many records you create per hour, do not expose v7 IDs.
  • It has fewer random bits. A v7 with 12 bits of sub-millisecond time keeps 62 random bits per millisecond, against 122 in v4. For collisions this is still remote: with 413 IDs in one millisecond and 74 random bits, the birthday approximation (n² ÷ 2 ÷ 2^bits) gives about 4.5 × 10⁻¹⁸. But a smaller random part is easier to guess, which matters for a link that grants access.

For a password-reset link or a shareable private URL, use a dedicated random token rather than any primary key.

Moving an existing table

Both versions use the same 16-byte uuid type, so a column can hold old v4 and new v7 values together; nothing needs converting. Changing the column default to a v7 generator is enough for new rows. The old v4 rows stay scattered through the index; only the new ones append at the end. On PostgreSQL 16 or 17, upgrading to 18 brings the built-in uuidv7(); our PostgreSQL 16 lifecycle entry lists how long 16 is supported, and the PostgreSQL 18 entry the newer release’s dates.