---
title: 'Postgres 18 UUIDv7 Primary Keys: Practical Guide (2026)'
description: 'Use Postgres 18''s built-in uuidv7() for primary keys: default DDL, uuidv7 vs uuidv4 vs bigint, migrating off uuidv4, and the timestamp-leak caveat to know.'
url: https://nerdleveltech.com/postgres-18-uuidv7-primary-keys
language: en
date: '2026-06-17'
category: backend
tags:
  - postgres
  - postgresql 18
  - uuidv7
  - primary keys
  - database
  - uuid
  - backend
  - architecture
markdown_url: https://nerdleveltech.com/postgres-18-uuidv7-primary-keys.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/postgres-18-uuidv7-primary-keys'
terms: https://nerdleveltech.com/llms.txt
---

# Postgres 18 UUIDv7 Primary Keys: Practical Guide (2026)

PostgreSQL 18 adds a built-in `uuidv7()` function, so you can use `uuid PRIMARY KEY DEFAULT uuidv7()` with no extension. UUIDv7 embeds a millisecond timestamp in its leading bits, so values sort by creation time and behave far more like a sequential key than random UUIDv4.[^docs][^credativ]

## TL;DR

PostgreSQL 18 (current stable 18.4)[^rel] ships native `uuidv7()`, `uuidv4()` (an alias for `gen_random_uuid()`), and updated `uuid_extract_timestamp()` / `uuid_extract_version()` helpers.[^docs] For a new table, just write `id uuid PRIMARY KEY DEFAULT uuidv7()`. UUIDv7's time-ordered prefix gives smaller, less-fragmented B-tree indexes than UUIDv4 — in one published benchmark the primary-key index was about 26–27% smaller and ordered scans ran roughly three times faster.[^credativ] The one real trade-off: a UUIDv7 leaks the row's approximate creation time. Every SQL block below was run against PostgreSQL 18.3 before publishing.

## What you'll learn

- Whether PostgreSQL 18 has a native UUIDv7 function and what shipped with it
- How to set `uuidv7()` as a default primary key with copy-paste DDL
- How UUIDv7 compares to UUIDv4 on index size, fragmentation, and speed
- When to choose UUIDv7 over a plain `bigint` identity column
- How to migrate an existing UUIDv4 table to UUIDv7 without losing order
- How to read the creation time back out of a UUIDv7
- Why a UUIDv7 leaks creation time and how to mitigate it

## Does PostgreSQL 18 have a built-in UUIDv7 function?

Yes. PostgreSQL 18 added `uuidv7()` as a core function — no `uuid-ossp` or `pgcrypto` extension required.[^docs] It generates a version 7 (time-ordered) UUID per RFC 9562, with the timestamp built from a UNIX millisecond value plus a sub-millisecond fraction plus random bits.[^docs]

The same release rounded out the UUID toolbox. `gen_random_uuid()` (version 4, fully random) is still there, and `uuidv4()` was added as a built-in alias for it so the naming matches `uuidv7()`.[^docs] Two extraction helpers were updated to understand v7: `uuid_extract_version(uuid)` returns the version number, and `uuid_extract_timestamp(uuid)` returns a `timestamp with time zone` for version 1 or 7 UUIDs (and `null` for others).[^docs]

```sql
SELECT
  uuid_extract_version(gen_random_uuid()) AS v4,  -- 4
  uuid_extract_version(uuidv7())          AS v7;  -- 7
```

UUIDv7 was added natively in 18; on PostgreSQL 17 and earlier you still need an extension or application-side library to generate it.[^docs]

## How do I use uuidv7() as a default primary key?

Set the column default to `uuidv7()`. Because the function runs server-side on every insert, you never touch the `id` in application code:

```sql
CREATE TABLE invoices (
    id         uuid PRIMARY KEY DEFAULT uuidv7(),
    amount     numeric,
    created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO invoices (amount)
SELECT g FROM generate_series(1, 5) AS g;

SELECT id, amount FROM invoices ORDER BY id;
```

Run against PostgreSQL 18.3, the rows come back in insertion order because the IDs are monotonic:

```text
                  id                  | amount
--------------------------------------+--------
 019ed46f-1d7f-7000-9fd1-6978e2d1412f |      1
 019ed46f-1d7f-7001-924f-a07704cd74c2 |      2
 019ed46f-1d7f-7002-817a-caaa62db219e |      3
 019ed46f-1d7f-7003-b9c9-fc99d1d64969 |      4
 019ed46f-1d7f-7004-abf1-966ff34e4974 |      5
```

That ordering is not luck. PostgreSQL stores a 12-bit sub-millisecond fraction right after the 48-bit timestamp and uses the RFC 9562 "replace leftmost random bits with increased clock precision" method, which guarantees monotonically increasing values for every UUIDv7 generated within the same session (backend process). Ordering is not guaranteed across different backends.[^sawada]

## Is UUIDv7 better than UUIDv4 for primary keys?

For index health, yes — and that is the whole point. UUIDv4 is random, so each insert lands at a random spot in the primary-key B-tree, causing page splits, low page density, and fragmentation. UUIDv7 inserts land at the right-hand edge of the index, like a sequential key.

A detailed PostgreSQL 18 benchmark by credativ (1M and 50M rows) measured the difference on real on-disk structures:[^credativ]

| Metric (1M rows) | UUIDv4 | UUIDv7 |
|---|---|---|
| Primary-key index size | ~40 MB | ~31.6 MB (~26–27% smaller) |
| Leaf pages | 4861 | 3832 |
| Average leaf density | 71% | ~90% |
| Leaf fragmentation | ~50% | 0% |
| `id` vs heap-order correlation | -0.0025 | 1.0 |
| `ORDER BY id` over 1M rows | ~318 ms | ~113 ms |

In that same test, inserting 50M rows into an empty table took about 20 minutes for UUIDv4 versus under 2 minutes for UUIDv7, and the gap widened on a second batch.[^credativ] Exact numbers depend on hardware, caching, and workload, but the direction is consistent: the time-ordered prefix keeps the index compact and inserts cheap. Note that the heap table size is identical — a UUID is 16 bytes either way; only the index layout changes.[^credativ]

## UUIDv7 vs bigint: which primary key should you use?

Use a `bigint` identity column when you are on a single node and want the smallest, simplest key; use UUIDv7 when you need globally unique, client- or distributed-generatable IDs without giving up index locality.[^cybertec]

A `bigint` is 8 bytes against a UUID's 16, so a `bigint` index entry is half the size — and because that key is copied into every secondary index and foreign key, the saving compounds across a schema — while a signed `bigint` still counts to about 9.2 × 10¹⁸ before overflowing.[^credativ] What `bigint` sequences cannot do is let many writers (microservices, mobile clients, sharded nodes) mint non-colliding keys independently, or hide the table's row count — a sequence value is guessable and reveals volume.[^cybertec]

UUIDv7 is the middle ground: it keeps the enormous 128-bit keyspace and distributed-generation property of UUIDs, while its index behaves close to a sequential `bigint` instead of the random mess UUIDv4 produces.[^credativ] For a brand-new single-server CRUD app, `bigint` identity is still a perfectly good default. The moment you anticipate distributed writes, data merges, or exposing IDs across services, UUIDv7 is the stronger primary key.

## How do I migrate an existing UUIDv4 table to UUIDv7?

Changing a live primary key is a heavy operation — you add a new column, backfill it, then move constraints and foreign keys over — so plan it like any schema migration. The mechanical part is adding a v7 column that defaults `uuidv7()` for new rows and backfilling old rows in a way that preserves their original order:

```sql
-- New rows get a real UUIDv7; existing rows are filled next.
ALTER TABLE legacy
  ADD COLUMN id_v7 uuid NOT NULL DEFAULT uuidv7();

-- Backfill old rows so the embedded timestamp matches each row's created_at.
UPDATE legacy
SET id_v7 = uuidv7(created_at - now());
```

The key detail is the `uuidv7(shift interval)` argument. `uuidv7()` with no argument embeds the current time; the optional `shift` offsets that time by an interval.[^docs] To make a backfilled UUID carry a row's original `created_at`, the shift must be `created_at - now()`. Verified against PostgreSQL 18.3, the round-trip is exact to the millisecond:

```sql
SELECT created_at,
       uuid_extract_timestamp(uuidv7(created_at - now())) AS embedded
FROM legacy ORDER BY created_at;
```

```text
       created_at       |        embedded
------------------------+------------------------
 2024-03-01 12:00:00+00 | 2024-03-01 12:00:00+00
 2024-06-15 08:30:00+00 | 2024-06-15 08:30:00+00
 2025-01-20 23:15:00+00 | 2025-01-20 23:15:00+00
```

Watch out for a formula copied around the web: `uuidv7((created_at - '2025-01-01')::interval)`. It looks plausible but does **not** embed `created_at` — it offsets *now* by the distance from a fixed anchor date, landing the timestamp years away from the real value. The same verified run shows a `2024-03-01` row coming back as `2025-08-15`. Always shift relative to `now()`. Also remember that any backfilled ID is *synthetic*: it reproduces ordering, not the genuine generation instant, which never existed for those legacy rows.

## Can I extract the creation time from a UUIDv7?

Yes — `uuid_extract_timestamp()` reads the embedded timestamp back out as a `timestamp with time zone`:

```sql
SELECT id, uuid_extract_timestamp(id) AS created_at_from_uuid
FROM invoices ORDER BY id LIMIT 1;
```

For a version 4 UUID the same function returns `null`, because there is no timestamp to recover.[^docs] The docs note the extracted value "is not necessarily exactly equal to the time the UUID was generated" — it reflects whatever the generator wrote — but for Postgres's own `uuidv7()` it is the insert-time clock reading.[^docs] This makes UUIDv7 handy for keyset pagination, log correlation, and time-series style scans where the key itself tells you roughly when a row was created.[^credativ]

## Does a UUIDv7 leak the row's creation time?

Yes, and it is the one caveat worth designing around. Because the leading 48 bits are a readable millisecond timestamp, anyone holding a UUIDv7 can call `uuid_extract_timestamp()` (or decode it client-side) to learn approximately when the row was created — and, across several IDs, infer your creation rate.[^credativ] For many internal tables that is harmless or even useful; for a public-facing identifier it can expose business signals like sign-up timing or order volume.

The common mitigation is to keep UUIDv7 as the *internal* primary key and expose a separate, fully random UUIDv4 (`gen_random_uuid()`) as the public-facing identifier, so external parties never see the time-ordered value.[^cybertec] If even an internal timestamp is sensitive, the `shift` argument lets you offset the embedded time by a constant to obscure the true creation date — at the cost of the timestamp no longer being meaningful.[^docs]

## FAQ

**Does PostgreSQL 18 have a built-in UUIDv7 function?**
Yes. `uuidv7()` is a core PostgreSQL 18 function — no extension needed. It generates an RFC 9562 version 7, time-ordered UUID.[^docs]

**How do I set uuidv7() as a default primary key?**
Declare the column as `id uuid PRIMARY KEY DEFAULT uuidv7()`. PostgreSQL runs the function on every insert, so you never set the ID yourself.[^docs]

**Is UUIDv7 better than UUIDv4 for primary keys?**
For index performance, yes. UUIDv7's time-ordered prefix produced an index about 26–27% smaller with zero leaf fragmentation versus ~50% for UUIDv4 in a published 18 benchmark, and ordered scans ran roughly 3× faster.[^credativ]

**Should I use UUIDv7 or bigint?**
Use `bigint` identity for the smallest, simplest single-node key; use UUIDv7 for globally unique, distributed-generatable IDs that still index like a sequential key. `bigint` is 8 bytes vs UUID's 16.[^cybertec][^credativ]

**How do I migrate from UUIDv4 to UUIDv7?**
Add a `uuid` column defaulting to `uuidv7()`, backfill existing rows with `uuidv7(created_at - now())` to preserve order, then swap constraints and foreign keys over. Backfilled IDs are synthetic.[^docs]

**Can I get the creation time from a UUIDv7?**
Yes — `uuid_extract_timestamp(id)` returns the embedded `timestamp with time zone`; it returns `null` for UUIDv4.[^docs]

**Does a UUIDv7 leak creation time?**
Yes. The timestamp is readable from the UUID. Keep UUIDv7 internal and expose a random UUIDv4 externally if that matters.[^cybertec]

## Bottom line and next steps

PostgreSQL 18's native `uuidv7()` finally makes time-ordered UUID primary keys a one-line default with no extensions, giving you UUID's distributed-friendly keyspace with index behavior close to a `bigint`.[^docs][^credativ] Reach for it when you need globally unique or client-generated IDs; stick with `bigint` identity when a single-node app just needs the smallest key; and isolate the timestamp leak behind a separate public ID when creation time is sensitive.

Because UUIDv7 keys are naturally ordered, they pair perfectly with [keyset (cursor) pagination in Postgres](https://nerdleveltech.com/cursor-pagination-postgres-nodejs-tutorial), which depends on a sortable key. If you are moving to PostgreSQL 18 to get `uuidv7()`, see the [zero-downtime Postgres 18 upgrade guide](https://nerdleveltech.com/postgres-18-zero-downtime-upgrade-pg-createsubscriber-tutorial), and once you are there, [pg_partman + pg_cron partition automation on Postgres 18](https://nerdleveltech.com/pg-partman-pg-cron-postgres-18-partition-automation-tutorial) works hand-in-hand with time-ordered keys for large tables.

[^docs]: PostgreSQL 18 Documentation — 9.14. UUID Functions. https://www.postgresql.org/docs/18/functions-uuid.html
[^rel]: PostgreSQL Global Development Group — "PostgreSQL 18.4, 17.10, 16.14, 15.18, and 14.23 Released!" (2026-05-14). https://www.postgresql.org/about/news/postgresql-184-1710-1614-1518-and-1423-released-3297/
[^credativ]: Josef Machytka, credativ — "A deeper look at old UUIDv4 vs new UUIDv7 in PostgreSQL 18" (2025-12-05). https://www.credativ.de/en/blog/postgresql-en/a-deeper-look-at-old-uuidv4-vs-new-uuidv7-in-postgresql-18/
[^sawada]: Masahiko Sawada (PostgreSQL committer) — "PostgreSQL 18 supports UUIDv7" (2025-09-04). https://masahikosawada.github.io/en/2025/09/04/UUIDv7-in-PostgreSQL/
[^cybertec]: CYBERTEC — "UUID, serial or identity columns for PostgreSQL auto-generated primary keys?" https://www.cybertec-postgresql.com/en/uuid-serial-or-identity-columns-for-postgresql-auto-generated-primary-keys/
