consolelog.tools logo
Back to Blog
Share:
UUID v4 vs v7: Which Should You Use in 2026?
GuideMay 4, 202612 min read

UUID v4 vs v7: Which Should You Use in 2026?

Time-ordered UUIDs (v7) trade a small bit of randomness for major database performance gains. Concrete tradeoffs, B-tree implications, decision matrix, and migration guide.

Mohammed Banani

Mohammed Banani

Author12 min read

0

Claps

UUID v4 vs v7: Which Should You Use in 2026?

For new database tables in 2026, default to UUIDv7. It gives you the same uniqueness guarantees as v4 while being sortable by creation time, which means your B-tree indexes stay healthy and your queries stay fast. Keep v4 for anything where the ID itself is a security token or where leaking creation time is a problem — API keys, password-reset links, public-facing identifiers you don't want to reveal timing information on.

That's the short version. Here's the reasoning.

What a UUID Actually Is

A UUID (Universally Unique Identifier) is a 128-bit value, typically represented as 32 hex characters split by hyphens: 550e8400-e29b-41d4-a716-446655440000. The spec originated with RFC 4122 in 2005, which defined versions 1 through 5. RFC 9562, published in May 2024, added versions 6, 7, and 8. The "version" field sits in bits 48–51 of the value, and the "variant" field identifies it as an RFC 9562 UUID. If you want the full bit-level spec, RFC 9562 is readable and precise — but for day-to-day decisions, the version is what actually matters.

UUIDv4: The Random Workhorse

UUIDv4 is 122 bits of cryptographically random data (6 bits are consumed by the version and variant fields). You generate one like this:

import { randomUUID } from 'crypto';

const id = randomUUID();
// e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479"

Or in SQL:

-- Postgres
SELECT gen_random_uuid();

What v4 does well:

  • Genuinely unpredictable. Without the source of entropy, an attacker learns nothing useful from the ID itself.
  • No coordination required between generators. Any process on any machine can produce a v4 UUID independently with essentially zero collision probability.
  • Universally supported. Every database, ORM, and language runtime has had v4 generation baked in for years.

Where v4 falls down:

The killer problem is index fragmentation. v4 UUIDs are random, which means every new row you insert lands at a random position in your B-tree index. At low volumes this is irrelevant. At scale — say, a table with tens of millions of rows — it causes a measurable problem:

Each insert requires the database to read a random index page into memory (a page that has likely been evicted from the buffer pool since the last time you touched it), insert the new key, and potentially split the page. Page splits are expensive: they require writing two new pages, updating parent pointers, and locking the affected range. Over time, this leaves you with a fragmented index where pages are only partially filled, your index is physically larger than it needs to be, and cache hit rates are poor because working-set data is scattered across hundreds of non-contiguous pages.

On a busy write-heavy service, the difference between random and sequential primary key inserts is not subtle. It shows up in CPU, I/O, and lock wait times.

UUIDv7: The Time-Ordered Upgrade

UUIDv7 solves the index fragmentation problem by embedding a timestamp in the most significant bits of the value. The bit layout from RFC 9562 looks like this:

 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                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Breaking this down:

  • Bits 0–47 (48 bits): Unix timestamp in milliseconds. At the time of writing, this gives you ordering precision good until the year 10889.
  • Bits 48–51 (4 bits): Version field, set to 0111 (7).
  • Bits 52–63 (12 bits): rand_a — random data, though implementors can use this for a sub-millisecond sequence counter to guarantee monotonicity within a single millisecond.
  • Bits 64–65 (2 bits): Variant field, set to 10.
  • Bits 66–127 (62 bits): rand_b — random data.

In practice, a v7 UUID looks like this:

018f5e3d-2a1b-7c4e-9a3f-1b2c3d4e5f6a
^^^^^^^^
|--- 48-bit millisecond timestamp (big-endian)

The timestamp occupies the leading bytes in big-endian order, so lexicographic sort order equals chronological insertion order. That is the entire point.

Monotonicity within a millisecond. If two UUIDs are generated in the same millisecond, the timestamp bits are identical. A well-implemented library uses rand_a as a monotonic counter for that millisecond, incrementing it by 1 for each ID generated. This prevents two IDs generated at t=1714900000000 from colliding or being arbitrarily ordered.

Generating v7 in TypeScript today (Postgres 18 adds it natively; until then you need application-level generation):

// Using the 'uuidv7' package — well-maintained, zero dependencies
import { uuidv7 } from 'uuidv7';

const id = uuidv7();
// e.g. "018f5e3d-2a1b-7c4e-9a3f-1b2c3d4e5f6a"
-- Postgres 18+ (native)
SELECT uuidv7();

-- Postgres 17 and earlier: use application-generated values
-- or install the pg_uuidv7 extension
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
SELECT uuid_generate_v7();

You can also generate and inspect UUIDs directly in your browser at consolelog.tools/tools/uuid-generator.

The B-tree Index Argument, with Numbers

The theoretical argument is straightforward: sequential inserts always append to the rightmost leaf page of the index, so the working set for writes is tiny and stays hot in the buffer pool. Random inserts scatter across the entire index.

In practice, this plays out clearly in benchmarks. Teams migrating from random UUIDs (v4) to time-ordered IDs have reported meaningful gains:

  • In Postgres benchmarks comparing sequential vs. random UUID inserts on large tables (10M+ rows), sequential IDs consistently show 40–60% lower write latency and significantly reduced index bloat.
  • Pinterest's engineering team documented roughly a 2x improvement in write throughput when they moved from random to time-ordered IDs in their MySQL tables — frequently cited in discussions about ID strategy.
  • The Postgres documentation itself notes that random primary keys cause "significant fragmentation" at scale and recommends sequential or time-ordered values for high-volume write tables.

The mechanism is simple. With v4:

Insert row with PK "f47ac10b-..." → random page somewhere in the middle of the index
Insert row with PK "3a9bcd00-..." → different random page, probably not in cache
Insert row with PK "d1e2f3a4-..." → yet another random page

Every insert is likely a cache miss. At sufficient scale, the database is constantly doing random I/O just to maintain the primary key index.

With v7:

Insert row with PK "018f5e3d-..." → appends to rightmost leaf page
Insert row with PK "018f5e3d-..." → same page, still in cache
Insert row with PK "018f5e40-..." → next page (1ms later), still hot

The buffer pool effect is dramatic on large tables. If your index is 10GB but only the last few pages are being written to, you need far less RAM to keep the write path hot.

Index bloat also improves. Because v7 inserts are sequential, pages fill up properly instead of getting split at random positions, resulting in a denser, smaller index.

Storage and Database Support

v4 and v7 are both 128-bit values. Same storage footprint. If you store them as the native uuid type in Postgres, that's 16 bytes — not 36 bytes like a text-encoded UUID string. Do not store UUIDs as text or varchar(36). You get twice the storage cost and slower comparisons for no reason.

Database v4 support v7 support
PostgreSQL 18+ gen_random_uuid() uuidv7() native
PostgreSQL 13–17 gen_random_uuid() pg_uuidv7 extension or app-generated
MySQL 8+ UUID() (actually v1) Application-generated
SQLite Application-generated Application-generated
SQL Server NEWID() (v4-like) Application-generated
MongoDB native ObjectID (time-ordered) Application-generated

For Postgres specifically, if you're on version 17 or earlier, the pg_uuidv7 extension (available on most hosted Postgres providers including Supabase and Neon) gives you uuid_generate_v7() without application changes.

ORM support is catching up quickly. Drizzle ORM supports defaultRandom() and custom defaults, making app-generated v7 trivial to wire in. Prisma, TypeORM, and Sequelize all support custom default functions.

// Drizzle ORM example
import { uuidv7 } from 'uuidv7';
import { pgTable, uuid } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
  // ...
});

The Privacy Tradeoff

UUIDv7 embeds a millisecond-precision Unix timestamp in the first 48 bits. Anyone who has the UUID can extract the creation time of that row to within one millisecond.

Most of the time, this is irrelevant. Your database primary keys are not public. An order_id in your URL probably shouldn't be the raw DB primary key anyway — use a separate public-facing ID or slug for that.

But there are specific scenarios where it matters:

Account enumeration. If your user id is v7 and you expose it in your API, an attacker can use two account IDs to infer your user growth rate. user_id_1 = 018f0000-... created at T1, user_id_2 = 018f5e3d-... created at T2 — the difference in timestamps tells them exactly how fast you're growing. Depending on your business context, this could be sensitive.

Security tokens. Never use a time-ordered ID as a bearer token, API key, or password-reset link. These need to be fully unpredictable. v4 or a dedicated CSPRNG output is the right choice here.

Leaked IDs in logs or URLs. If a v7 UUID ends up in a public URL or log that gets scraped, the creation time of the associated record is visible. Again, usually not a problem if you're careful about what IDs appear where.

For the large majority of backend primary keys — records that only appear in API responses to authenticated users — the timestamp leakage is a theoretical concern with no practical consequence.

Decision Matrix

Situation Recommendation Reason
Database primary key (new table) v7 Sequential inserts, better index performance
Primary key on existing v4 column Keep v4 or migrate carefully Migration cost vs. benefit calculation
Security token (API key, reset link) v4 or random bytes Must be fully unpredictable
Public-facing ID in URL v4, or a separate slug Avoid leaking creation time
Event log / audit table v7 Built-in chronological ordering
Already using ULID or Snowflake Keep using it v7 is equivalent; no migration value
Cross-service correlation ID v7 Easier to reason about ordering across services
Cryptographic nonce Neither — use crypto.getRandomValues() UUIDs are not designed for this

The ULID comparison is worth noting: ULID (Universally Unique Lexicographically Sortable Identifier) and Snowflake IDs were popular pre-RFC-9562 solutions to the same problem v7 solves. If you've standardized on them, there's no compelling reason to migrate. v7 is just the now-standardized version of that idea, with the advantage that native database support is arriving.

Adding v7 to Your Schema

If you're starting a new table, the setup is straightforward:

-- Postgres 17 with pg_uuidv7 extension
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;

CREATE TABLE orders (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
  user_id uuid NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  -- ...
);

If you want to add v7 to an existing table that uses v4, you don't need to rewrite anything. New rows can use v7, old rows keep their v4 IDs, and Postgres stores them identically as the uuid type. The only thing you lose is perfect ordering across the migration boundary — pre-migration rows will sort after all post-migration rows if you sort by ID, which may or may not matter depending on whether you actually rely on ID ordering.

-- Adding a new table alongside existing v4 tables — completely fine
-- Old table: unchanged
ALTER TABLE existing_table ADD COLUMN IF NOT EXISTS nothing_changes uuid;

-- New table: use v7 from day one
CREATE TABLE new_events (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
  -- ...
);

If you need to migrate an existing high-volume table from v4 to v7 primary keys, the most practical approach is: add a new id_v7 column, backfill it, deploy application code that writes to both columns, run a zero-downtime swap using a view or rename, then drop the old column. That's a separate operational procedure beyond the scope of this post.

Common Pitfalls

Storing UUIDs as text. The uuid type in Postgres is 16 bytes. varchar(36) is 36 bytes, slower to index, and slower to compare. Always use the native uuid type.

-- Wrong
id varchar(36) PRIMARY KEY DEFAULT uuid_generate_v7()::text

-- Right
id uuid PRIMARY KEY DEFAULT uuid_generate_v7()

Using v7 as a cryptographic token. A v7 UUID has 74 bits of randomness (62 bits in rand_b + 12 bits in rand_a). That's technically adequate for uniqueness but it is not a security token. The timestamp prefix makes it partially predictable. For API keys, session tokens, or anything where an attacker might try to brute-force or predict values, use crypto.randomBytes(32) and encode the result as hex or base64.

Clock skew between machines. If you generate v7 UUIDs across multiple application servers, clock differences can cause minor ordering inversions. Server A might generate 018f5e40-... and Server B, running 5ms behind, might generate 018f5e3b-... — which sorts before A's ID even though it was generated later. For most use cases this is fine: the IDs are still highly ordered, just not perfectly ordered across machines. If you need strict monotonic ordering across servers, use a centralized sequence or Snowflake-style IDs with machine IDs baked in.

Forgetting that ORDER BY id is not the same as ORDER BY created_at. Yes, v7 IDs sort roughly by creation time. But they're not a substitute for a real created_at column. If you need accurate, human-readable timestamps, store them explicitly. The ID ordering is a performance optimization for indexes, not a replacement for your audit trail.

Assuming all UUID libraries generate v7 correctly. Some older libraries labeled "UUID v7" don't implement the monotonic counter for sub-millisecond generation correctly, which can cause ordering inversions within a single millisecond on high-throughput systems. The uuidv7 npm package and Postgres's native uuidv7() function both implement this correctly. Check your library's compliance with RFC 9562 if throughput matters.


For new Postgres tables in 2026, default to v7. The index performance argument alone justifies it, Postgres 18 ships with native support, and the ecosystem has caught up. Reserve v4 for tokens and public-facing identifiers where you explicitly don't want time information embedded.

Generate, inspect, and decode both versions in your browser at consolelog.tools/tools/uuid-generator.

Tags

uuiddatabasepostgresidentifiersbackendperformance

Join Other Developers

Get weekly tutorials, tool releases, and developer tips delivered straight to your inbox.

Join developers from Google, Meta, Amazon, and more. Unsubscribe anytime.

Try our developer tools

Explore 294+ free online tools for developers. No installation, no registration, works offline.

Browse All Tools