Both are 128-bit identifiers in the same textual format. The difference is what fills the bits, and that difference decides whether your primary key index stays healthy at ten million rows.
What each one is
v4 is 122 random bits plus 6 bits of version and variant metadata. Every value is independent of every other. Two v4s generated a millisecond apart sort nowhere near each other.
v7 puts a 48-bit Unix millisecond timestamp in the leading bits, then fills the rest with randomness. Two v7s generated a millisecond apart sort adjacently, and any v7 sorts after every v7 generated before it.
v4 9f1c2d3e-8b4a-4c5d-9e6f-1a2b3c4d5e6f
v7 01929c4a-7f3b-7c5d-9e6f-1a2b3c4d5e6f
└── timestamp ──┘└── random ──────┘
Free toolUUID GeneratorGenerate cryptographically random UUID v4s, or time-ordered UUID v7s that index far better as database primary keys.
Why v4 hurts as a primary key
A B-tree index stores keys in sorted order. When you insert a value, the database finds the right leaf page and writes into it.
With sequential keys, every insert lands on the same rightmost page. That page stays in memory, fills up, splits once, and the process repeats. One hot page, near-perfect page utilisation.
With random keys, every insert lands on a different page, scattered across the whole index. That means:
- Random reads before every write. The target page probably is not in the buffer pool, so it must be read from disk first.
- Constant page splits. Inserting into the middle of a full page splits it in two, each half-empty. Index size grows to roughly 1.5–2x what a sequential key needs.
- Write amplification. In Postgres, every touched page is written to the WAL in full the first time it is modified after a checkpoint. Scattered writes touch far more pages.
At a hundred thousand rows none of this is measurable. At ten million with an index larger than RAM, insert throughput can drop by an order of magnitude.
v7 has none of these problems, because it appends. It behaves almost exactly like a sequential integer key while keeping the properties people choose UUIDs for.
What you keep with v7
Client-side generation. The whole point of a UUID: generate the ID before the round trip, so you can build the object graph offline, batch inserts, and avoid a RETURNING id round trip per row.
No collisions across systems. Merge two databases without renumbering anything.
No enumeration. A sequential integer in a URL tells everyone how many customers you have and lets them walk your data by incrementing. A UUID does not.
What you lose with v7
Timestamps are public. The creation time is right there in the first 48 bits of every ID you expose. For most applications this is irrelevant. If your IDs appear in URLs and creation time is sensitive - a private beta signup order, say - v7 leaks it.
That is the only real trade-off. It is a smaller problem than it sounds, and a much smaller problem than a bloated index.
Storage: do not store UUIDs as text
A UUID is 16 bytes. The canonical string form is 36 characters, and stored as varchar in a UTF-8 database that is 37 bytes with the length header - more than double.
- Postgres: use the native
uuidtype. 16 bytes, indexes properly. - MySQL:
BINARY(16), withUUID_TO_BIN/BIN_TO_UUIDto convert. MySQL'sUUID_TO_BIN(uuid, 1)also swaps the time fields of a v1 UUID to make it sortable - unnecessary for v7, which is already sortable. - SQLite:
BLOB, or accept the text overhead if the table is small.
Storing as text also breaks range scans, because lexical string ordering only matches value ordering if the encoding is fixed-width and uppercase-consistent.
When to use what
Use v7 for primary keys on anything that will grow - the default choice for new tables in 2026.
Use v4 for anything where unlinkability matters more than index locality: public share tokens, idempotency keys, correlation IDs in logs, anything a user might compare against another.
Use a bigint when rows are only ever created by your own server, IDs never appear in a URL, and you will never merge databases. It is 8 bytes instead of 16, and it is faster than both. Do not adopt UUIDs by default if you do not need what they give you.
Never use v1. It embeds the MAC address of the generating machine, which is an information leak with no upside now that v7 exists.
Generating v7 without a library
function uuidV7() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
const stamp = Date.now();
for (let i = 0; i < 6; i++) {
bytes[i] = Math.floor(stamp / 2 ** (8 * (5 - i))) & 0xff;
}
bytes[6] = (bytes[6] & 0x0f) | 0x70; // version 7
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
Two details people get wrong: the version nibble must be set to 7 in byte 6, and the variant bits in byte 8 must be 10. Skip either and the value is a random 128-bit number that no parser will recognise as a v7 UUID.
Postgres has no built-in v7 generator before version 18. On older versions, generate in the application or add a small PL/pgSQL function - do not fall back to gen_random_uuid() (which is v4) for a table you expect to grow.
Launch it where the numbers are checked
RankCert ranks products on domain control we verify ourselves. Listing is free and the link stays dofollow whether or not you display the badge.
