UUIDv7 in PostgreSQL 18: Faster, Smaller Primary Keys
Why random UUIDv4 keys bloat PostgreSQL indexes, how UUIDv7 and uuidv7() in PostgreSQL 18 fix it, our 5-million-row lab results, and how to adopt UUIDv7 in Python, Django and Node.

Most teams switch to UUID primary keys for good reasons. IDs can be created before a row exists, records from different databases can be merged without collisions, and public URLs stop leaking how many orders or customers you have. Then the table grows. Inserts that used to be instant start to wobble, the primary key index turns out to be far larger than anyone expected, and the database needs more memory just to keep up with writes.
The cause is usually not the UUID type itself. It is the randomness of UUIDv4. Every new key lands at a random position in the B-tree index, so PostgreSQL keeps splitting half-full pages and touching parts of the index that were cold a moment ago.
UUIDv7 fixes most of that while keeping the reasons you chose UUIDs in the first place. PostgreSQL 18 ships a native uuidv7() function, Python 3.14 added uuid.uuid7(), and Django 6.1 added a UUID7 database function. This guide explains how UUIDv7 works, what we measured on PostgreSQL 18.6, how to adopt it on new and existing tables, and where it is the wrong choice.
Why random UUID primary keys get slow
A PostgreSQL primary key is backed by a B-tree index. B-tree pages hold keys in sorted order. When keys arrive in increasing order, like a bigint identity column, every insert goes to the rightmost leaf page. PostgreSQL fills that page, starts a new one, and the pages behind it are never touched again. The hot part of the index is tiny and stays in memory.
UUIDv4 values are 122 bits of randomness. Consecutive inserts land on unrelated leaf pages across the whole index. That has three costs:
- Page splits everywhere. When a random key lands on a full page, PostgreSQL splits it into two half-full pages. Over time the index settles at a lower fill level, so it needs more pages for the same number of keys.
- A working set the size of the whole index. Because any page can receive the next insert, the entire index has to stay in shared buffers or the OS cache to avoid disk reads on write. Once the index outgrows memory, insert latency climbs.
- More WAL after each checkpoint. The first change to a page after a checkpoint writes a full page image to the write-ahead log. Random inserts touch many distinct pages between checkpoints, so they trigger more of those images than inserts that stay on a few pages at the end of the index.
None of this shows up in development, where everything fits in memory. It shows up months later on orders, events, webhook deliveries and audit logs, the tables that grow fastest.
What UUIDv7 actually is
UUIDv7 is defined in RFC 9562, the 2024 standard that replaced RFC 4122. It keeps the familiar 128-bit, 36-character format, but changes what the bits mean. The most significant 48 bits are a Unix timestamp in milliseconds, followed by the version number, extra bits that the generator can use for ordering within a millisecond, the variant, and random data at the end.

Because the time comes first, new UUIDv7 values are always larger than values created earlier, give or take clock differences between machines. To a B-tree, that looks almost like a sequence: new keys go to the right edge of the index. You still get uniqueness without coordination.
The trade-off is in the name. A UUIDv7 contains its creation time, and anyone holding the value can read it. We cover when that matters further down.
What PostgreSQL 18 gives you
Before version 18, UUIDv7 in PostgreSQL meant an extension, a hand-written PL/pgSQL function, or generating values in the application. PostgreSQL 18 added the following built-in UUID functions:
uuidv7()generates a time-ordered UUID. It combines millisecond Unix time, a sub-millisecond fraction and random bits, which helps keep values in order even when many are created in the same millisecond.uuidv7(shift interval)moves the embedded timestamp by the given interval, for exampleuuidv7(INTERVAL '-30 days')when backfilling historical rows.uuidv4()is a new, explicit alias forgen_random_uuid().uuid_extract_timestamp(uuid)returns the embedded time for version 1 and 7 values, andNULLfor others.uuid_extract_version(uuid)tells you which version a stored value is.
No extension is needed. A new table with a UUIDv7 key is one line:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_id uuid NOT NULL,
total_cents bigint NOT NULL CHECK (total_cents >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
PostgreSQL 19 adds a small convenience on top: explicit casts between uuid and bytea, so you can turn a UUID into a compact base64 or hex string with encode() and back with decode(). At the time of writing, PostgreSQL 19 is at the release candidate stage, and the project's RC 1 announcement plans general availability for November 19, 2026. For UUIDv7 itself, version 18 is all you need.
What we measured on PostgreSQL 18.6
We wanted our own numbers rather than repeating someone else's, so we ran a small lab on a single Linux machine with PostgreSQL 18.6, shared_buffers set to 256 MB and autovacuum turned off during the run so that background work would not distort the WAL figures. Three tables had identical columns (a key, an MD5 text payload and a created_at timestamp). Only the primary key changed: bigint identity, uuidv4() and uuidv7(). Each table received 5,000,000 rows in batches of 50,000 rows per INSERT ... SELECT. We then measured the primary key index with the pgstattuple extension.

- Index size: 107 MB for
bigint, 192 MB for UUIDv4, 150 MB for UUIDv7. The UUIDv7 index is about 22% smaller than the UUIDv4 one for the same rows. - Leaf density: about 90% for both
bigintand UUIDv7, but about 71% for UUIDv4, with roughly half its leaf pages out of physical order. That is the page-split pattern described above. - Insert time: 13.0 seconds for
bigint, 30.5 seconds for UUIDv4, 18.0 seconds for UUIDv7. - WAL written: 867 MB, 1,004 MB and 934 MB respectively.
Read these numbers as a direction, not a promise. This was one machine, one run, bulk batches rather than many concurrent single-row transactions, and an index that still fit in memory. Because we took a checkpoint right before each load, the run also did not capture the full-page-image cost that random inserts pay in a busy system with regular checkpoints. Real workloads with indexes larger than RAM usually see a bigger gap between UUIDv4 and UUIDv7, not a smaller one. The other lesson is that bigint is still the cheapest key. UUIDv7 closes most of the gap with sequences; it does not erase the cost of a 16-byte key.
To test your own workload, run the same comparison against a copy of your real schema and traffic, and compare index size, p95 and p99 insert latency, and cache hit ratio. Our guide to PostgreSQL slow query optimization covers the tooling for that kind of investigation.
Generating UUIDv7 in your application
You do not have to let the database generate the value. Generating IDs in the application is useful when you need the ID before the insert, for example to publish an event, build a URL or link child records in the same request.
Python 3.14
Python 3.14 added uuid.uuid7() to the standard library uuid module. It embeds a 48-bit millisecond timestamp and uses a counter to stay monotonic within a millisecond.
import uuid
order_id = uuid.uuid7()
print(order_id.version) # 7
print(order_id.time) # milliseconds since the Unix epoch
Django 6.1
Django has two options. With Python 3.14 you can pass the standard library function as a Python-side default, which works on every database backend:
import uuid
from django.db import models
class Order(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid7, editable=False)
Django 6.1 also added UUID4 and UUID7 database functions, so the database can fill in the key through db_default. UUID7 works on PostgreSQL 18 or later and MariaDB 11.7 or later, and on SQLite when running Python 3.14:
from django.db import models
from django.db.models.functions import UUID7
class WebhookDelivery(models.Model):
id = models.UUIDField(primary_key=True, db_default=UUID7(), editable=False)
Pick one source per table. Mixing Python-side and database-side generation on the same column works, but it makes ordering harder to reason about when the application servers and the database disagree slightly about the time.
Node.js and TypeScript
The widely used uuid package exports a v7 function:
import { v7 as uuidv7 } from 'uuid';
const eventId = uuidv7();
Whatever the language, store the value in a native uuid column. In our lab, pg_column_size reported 16 bytes for a uuid value and 40 bytes for the same value as text, and text keys also compare more slowly.
Moving an existing UUIDv4 table to UUIDv7
You do not need to rewrite existing IDs, and in most systems you should not. Primary keys live on in foreign keys, caches, logs, URLs, webhooks and other systems' databases. Changing them is risky and gains little.
Instead, change the default so new rows get UUIDv7 values:
ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();
This is a catalog-only change: it takes a brief lock but does not rewrite the table. The uuid column accepts any version, so old UUIDv4 rows and new UUIDv7 rows live side by side. We checked this in our lab: after switching the default, uuid_extract_version() returned 4 for old rows and 7 for new ones, and uuid_extract_timestamp() returned NULL for the old rows.
What changes is where new keys land. UUIDv7 values all sort into one region of the key space, so new inserts concentrate on a small, hot part of the index instead of spraying across it. The old, fragmented part of the index stays as it is; if its size matters, plan a REINDEX INDEX CONCURRENTLY during a quiet period. Treat that like any other production migration. Our article on zero-downtime database migrations with Django and PostgreSQL covers lock timeouts and rollout order.
If you are still on PostgreSQL 17 or earlier, generate UUIDv7 in the application with one of the libraries above. The storage and index benefits are the same; only the generator moves.
Where UUIDv7 fits well
- Multi-tenant SaaS tables. Orders, invoices, tickets and documents that grow without limit and are often created from several services. See our guide to multi-tenant SaaS architecture for how keys interact with tenant isolation.
- Event, outbox and audit tables. Rows are appended in time order and usually read in time order. A UUIDv7 key gives you both uniqueness and a natural sort, which suits a transactional outbox relay that drains rows oldest-first.
- Client-generated IDs. Mobile apps, offline-first web apps and integrations that create records before they reach your API can generate UUIDv7 locally and retry safely without creating duplicates.
- Sync and merge jobs. Records from regional databases, imports or partner systems can be combined without renumbering.
- Keyset pagination.
WHERE id > $last_id ORDER BY id LIMIT 50returns rows in roughly creation order using only the primary key index.
Common mistakes with UUIDv7
Treating it as a secret
RFC 9562 is explicit that UUIDs must not be used as security capabilities, meaning identifiers whose possession alone grants access, and recommends UUIDv4 if a UUID is used for any security purpose. Password reset links, invitation links and signed download URLs need a proper random token, plus normal authorization checks. A hard-to-guess ID is never a replacement for checking that the caller may see the record.
Forgetting that the ID reveals creation time
Anyone who sees a UUIDv7 can read when the row was created. For order IDs in a customer's own account that is rarely a problem. For user IDs on a public profile, or document IDs shared outside the company, it might be. The shift argument only moves the timestamp by a fixed amount, so it hides the absolute date from casual readers but still reveals the order and spacing of records. If creation time must stay private, use UUIDv4 for that identifier or expose a separate random public ID.
Relying on strict global ordering
PostgreSQL and Python both work to keep values ordered within one session or process. Across several application servers, ordering is only as good as their clocks. Use UUIDv7 ordering for index locality and rough sorting, not as proof that event A happened before event B. If ordering matters to business logic, store a sequence number or a timestamp from one source of truth.
Dropping created_at
It is tempting to read creation time from the key with uuid_extract_timestamp() and drop the timestamp column. Keep created_at. Backfilled rows, imported rows and rows generated with a shift will carry a key timestamp that differs from the real insert time, and a plain timestamptz column is easier to index, filter and explain in reports.
Expecting it to beat bigint
UUIDv7 is a better UUID, not a better integer. If a table is only ever written by one database and its IDs never leave your system, a bigint identity column is still smaller and faster.
Recommendations

- Make UUIDv7 the default for new UUID primary keys on PostgreSQL 18, using
DEFAULT uuidv7()or Django'sUUID7. - Keep
bigintidentity for internal tables that never need IDs generated elsewhere. - Keep random tokens random. Use UUIDv4 or a dedicated token generator for anything that grants access or must hide creation time.
- Change defaults, not history. On existing UUIDv4 tables, switch the default to UUIDv7 and leave old IDs alone.
- Store
uuid, not text, and keep a realcreated_atcolumn. - Measure on your own data before and after: index size, p95 and p99 insert latency, and cache hit ratio.
FAQ
Does PostgreSQL 17 support uuidv7()?
No. The built-in uuidv7() function arrived in PostgreSQL 18. On earlier versions, generate UUIDv7 in the application and store it in a normal uuid column.
Can UUIDv4 and UUIDv7 values share one column?
Yes. The uuid type stores any version, and foreign keys, joins and equality lookups work the same. Use uuid_extract_version() if you need to tell them apart.
Should I convert my existing IDs?
Usually not. Switch the default for new rows and leave existing keys in place. Rewriting primary keys touches every foreign key and every external system that stored the old IDs.
Conclusion
Random UUID primary keys solve real problems and create a quiet one: an index that grows less dense and harder to cache with every insert. UUIDv7 keeps the benefits of UUIDs while giving PostgreSQL keys that arrive in roughly increasing order. With uuidv7() in PostgreSQL 18, uuid.uuid7() in Python 3.14 and UUID7 in Django 6.1, adopting it on a new table takes one line and on an existing table takes one ALTER TABLE. Keep random tokens for anything secret, keep bigint where it already fits, and measure the change on your own workload.
If you are planning a PostgreSQL upgrade, a schema change on a large SaaS table or a broader database performance review, talk to CodeSapient. You can also see the backend and SaaS engineering services we offer.
