UUID v7 in PostgreSQL: Native Support, Extensions, and Benchmarks

PostgreSQL 18, released in September 2025, added native support for UUIDv7 — a uuidv7() function generating RFC 9562-compliant, time-ordered UUIDs directly in core, no extension required. This post covers exactly what shipped, how to generate v7 UUIDs on Postgres versions that predate it, and what the native function actually changes at the index level, with reproducible benchmarks.

What Shipped in PostgreSQL 18

Four UUID-related additions landed together in the 18 release:

Function Purpose
uuidv7() Generates a version 7 (time-ordered) UUID
uuidv4() New explicit alias for the existing gen_random_uuid() — same output, clearer naming now that both versions have first-class functions
uuid_extract_timestamp(uuid) Extracts the embedded timestamp from a v7 (or v1) UUID as a timestamptz
uuid_extract_version(uuid) Returns the UUID's version number, for verifying which generator produced a given value
-- PostgreSQL 18+
SELECT uuidv7();
-- e.g. 0197f96c-b278-7f64-a32f-dae3cabe1ff0

SELECT uuid_extract_version(uuidv7());
-- 7

SELECT uuid_extract_timestamp(uuidv7());
-- 2026-08-08 14:22:01.842+00 — the exact generation moment, decoded directly

That uuid_extract_timestamp() addition is worth calling out specifically: it means a v7 primary key can, in a genuine sense, double as its own created_at column for many purposes — no separate indexed timestamp column required just to answer "when was this row created" or "give me the most recent N rows."

-- Ordering by a v7 primary key is equivalent to ordering by creation time,
-- without needing a separate created_at column or index
SELECT id, uuid_extract_timestamp(id) AS created_at, total
FROM orders
ORDER BY id DESC
LIMIT 10;

The Bit Layout, Specifically as Postgres Implements It

Per RFC 9562, a UUIDv7 value's structure is:

| 48 bits          | 4 bits  | 12 bits        | 2 bits  | 62 bits       |
| Unix ms timestamp| version | sub-ms/random  | variant | random        |

Postgres's implementation uses the 12 bits following the version field for additional sub-millisecond precision combined with randomness, which the RFC permits as an implementation choice (it's specified as either extra timestamp precision or pure random fill). The practical result: two UUIDv7 values generated in the same process within the same millisecond still differ, and the specific ordering within that millisecond is not guaranteed — only millisecond-level ordering is guaranteed across the value as a whole.

Generating UUIDv7 on Postgres Versions Before 18

If the target deployment is on Postgres 13–17, native uuidv7() isn't available, and there are three realistic paths.

Generate the v7 UUID in application code and pass it as a literal — this works identically on any Postgres version, since the database just stores whatever valid UUID it receives:

CREATE TABLE orders (
    id UUID PRIMARY KEY,  -- no DEFAULT; value supplied by the application
    tenant_id INT NOT NULL,
    total NUMERIC(10,2) NOT NULL
);
// .NET 9+: built-in
var id = Guid.CreateVersion7();

// .NET 8 and earlier: implement per RFC 9562 (see the UUIDv4 vs UUIDv7
// comparison article for a full reference implementation)
const { v7: uuidv7 } = require('uuid'); // npm package, works on any Postgres version
const id = uuidv7();

Option 2: A SQL/PLpgSQL Implementation

For cases where DB-side generation is required (e.g., inserts happening outside application code, or wanting a single source of truth for ID format), a function can be defined manually:

CREATE OR REPLACE FUNCTION uuidv7_compat() RETURNS uuid AS $$
DECLARE
    unix_ts_ms bytea;
    uuid_bytes bytea;
BEGIN
    unix_ts_ms := substring(int8send(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3 FOR 6);
    uuid_bytes := unix_ts_ms || gen_random_bytes(10);

    -- Set version (7) in the appropriate nibble
    uuid_bytes := set_byte(uuid_bytes, 6, (get_byte(uuid_bytes, 6) & 15) | 112);
    -- Set variant bits
    uuid_bytes := set_byte(uuid_bytes, 8, (get_byte(uuid_bytes, 8) & 63) | 128);

    RETURN encode(uuid_bytes, 'hex')::uuid;
END;
$$ LANGUAGE plpgsql VOLATILE;

-- Requires pgcrypto for gen_random_bytes()
CREATE EXTENSION IF NOT EXISTS pgcrypto;

This is functionally equivalent to native uuidv7() but carries the interpreted-function overhead of PL/pgSQL versus a C-implemented built-in — measurable at high insert volume, though rarely the bottleneck compared to disk I/O.

Option 3: A Purpose-Built Extension

Community extensions (e.g., pg_uuidv7) provide a compiled, C-level implementation with performance closer to what shipped natively in 18. This is the right choice when staying on an older major version long-term and generation-function overhead genuinely matters at your insert volume — otherwise, application-layer generation (Option 1) is simpler to operate and equally effective for the index-locality benefit, since that benefit comes from the value's bit layout, not from where it was generated.

Benchmark: Native uuidv7() vs gen_random_uuid() on PG18

CREATE TABLE bench_v4 (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), payload TEXT DEFAULT 'x');
CREATE TABLE bench_v7 (id UUID PRIMARY KEY DEFAULT uuidv7(), payload TEXT DEFAULT 'x');
CREATE TABLE bench_bigint (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, payload TEXT DEFAULT 'x');

INSERT INTO bench_v4 (payload) SELECT 'x' FROM generate_series(1, 5_000_000);
INSERT INTO bench_v7 (payload) SELECT 'x' FROM generate_series(1, 5_000_000);
INSERT INTO bench_bigint (payload) SELECT 'x' FROM generate_series(1, 5_000_000);

Published benchmarks on this exact comparison (50M-row bulk insert workloads on PG18) show bulk inserts completing in roughly 1.8 minutes with v7 versus roughly 20 minutes with v4, alongside meaningfully smaller resulting indexes and notably faster range scans — a striking gap that stems directly from the append-vs-scatter insert pattern difference. The methodology below reproduces the same class of result at a smaller, more locally-runnable scale.

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT 'bigint' AS variant, avg_leaf_density, leaf_pages FROM pgstatindex('bench_bigint_pkey')
UNION ALL
SELECT 'v7', avg_leaf_density, leaf_pages FROM pgstatindex('bench_v7_pkey')
UNION ALL
SELECT 'v4', avg_leaf_density, leaf_pages FROM pgstatindex('bench_v4_pkey');
Variant avg_leaf_density leaf_pages (relative)
bigint (baseline) ~90% 1.0x
uuidv7() ~88-90% ~1.05-1.1x
gen_random_uuid() (v4) ~60-65% ~1.6-1.7x

The v7 index tracks close to the sequential-integer baseline — the timestamp prefix produces essentially the same append-mostly insertion geometry. The v4 index requires substantially more leaf pages to hold the same row count, consistent with the fill-factor degradation covered in depth in the UUID Index Bloat article. Exact percentages vary by hardware, Postgres configuration, and concurrent load — run the pgstatindex() queries above against your own environment for a number you can trust over any figure quoted in a blog post, including this one.

The Timestamp-Leakage Caveat

Because uuidv7()'s output directly encodes generation time in its leading bits, exposing v7 UUIDs as public-facing identifiers (URLs, API responses) reveals approximately when each record was created — and by extension, relative creation order between any two exposed IDs. For internal primary keys never exposed to end users, this is irrelevant. For public identifiers where creation-time or volume information is sensitive (e.g., an ID that shouldn't reveal how many orders a competitor's store has processed, or when a specific record was created), this is a real, non-hypothetical consideration — not a theoretical edge case.

-- Mitigation: keep uuidv7() as the internal primary key for its index benefits,
-- expose a separate, opaque identifier publicly
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT uuidv7(),         -- internal, indexed, never exposed
    public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,  -- v4, safe to expose
    tenant_id INT NOT NULL,
    total NUMERIC(10,2) NOT NULL
);

This pattern gets the indexing benefit of v7 internally while keeping the externally-visible identifier fully opaque — at the cost of a second UUID column and its own unique index.

Comparison With Alternatives

Approach Native in PG18+ Native pre-PG18 Index locality Timestamp exposed
uuidv7() Yes No — extension or app-layer needed Excellent Yes
gen_random_uuid() / uuidv4() Yes Yes (PG13+) Poor No
BIGSERIAL/IDENTITY Yes Yes Best Yes (via value itself, more directly than v7)
uuid-ossp's uuid_generate_v1() Requires extension Requires extension Good (time-based) Yes, plus embeds MAC address
Application-generated v7 (any PG version) N/A Works on any version Excellent Yes

Migration Path for Existing v4 Tables

Switching the default generator doesn't require touching existing rows — old v4 values remain valid:

-- Requires PostgreSQL 18+
ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();

New rows benefit from the improved insert locality immediately; historical v4 rows are unaffected and continue to compare and index correctly, since uniqueness and comparison operate on the raw 128 bits regardless of which version generated them.

Conclusion

PostgreSQL 18's native uuidv7(), together with uuid_extract_timestamp() and uuid_extract_version(), removes the last real friction point for using time-ordered UUIDs as primary keys in Postgres — no extension, no PL/pgSQL workaround, and a built-in way to decode the embedded timestamp for operational queries. For deployments still on 13–17, application-layer generation is the simplest path to the same index-locality benefit today, since the benefit comes entirely from the value's bit layout rather than from where it's generated. The one tradeoff that doesn't go away with native support is timestamp exposure — for internal keys it's a non-issue, for public-facing identifiers it's worth a deliberate decision, not an afterthought..


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.