UUIDv7 from PostgreSQL 18 in practice

UUIDv7 from PostgreSQL 18 in practice

PostgreSQL 18 quietly removed one of those small pieces of application glue that many teams have been carrying for years. You can now ask the database for a UUIDv7 directly with uuidv7().

That sounds like a tiny feature until you remember why UUIDs became common primary keys in web apps in the first place. Sequential integers are wonderfully boring inside a database, but the moment they leak into URLs, APIs, logs, webhook payloads, support tickets, and browser history, they start telling stories you may not want to tell.

If /orders/142 exists, there is a decent chance /orders/143 exists too. Even when your authorization is correct, sequential IDs can reveal volume, growth, and operational shape. When authorization is wrong in one forgotten endpoint, they make the bug painfully easy to exploit.

UUIDv4 solved that visibility problem with randomness. UUIDv7 keeps most of that practical opacity while giving the database something much friendlier to index: time order.

What PostgreSQL 18 actually added

PostgreSQL 18 ships the uuidv7() function as a built-in UUID generator. The official UUID function docs describe it as a version 7, time-ordered UUID whose timestamp uses Unix time with millisecond precision, sub-millisecond timestamp bits, and random data. PostgreSQL 18 also includes uuid_extract_timestamp() and uuid_extract_version(), which makes it easier to inspect generated values when you are testing or migrating.

The PostgreSQL 18 release notes call this out as a developer-experience feature because it is not only about nicer identifiers. It is about the shape of your B-tree indexes under write load.

UUIDv4 is intentionally random. That is excellent for unpredictability, but rough on locality. New rows do not naturally land near recently inserted rows in the primary key index. UUIDv7 puts time first, so recently generated IDs tend to sort close together while still keeping random bits for uniqueness and practical non-enumerability.

A small schema example

The simplest PostgreSQL 18 usage is a default value on a uuid primary key.

CREATE TABLE invoices (
  id uuid PRIMARY KEY DEFAULT uuidv7(),
  customer_email text NOT NULL,
  total_cents integer NOT NULL CHECK (total_cents >= 0),
  created_at timestamptz NOT NULL DEFAULT now()
);

That is the whole trick. No extension call in the migration. No application-side UUID factory. No custom database function maintained from a gist you copied three years ago.

You can insert rows without passing an ID.

INSERT INTO invoices (customer_email, total_cents)
VALUES
  ('[email protected]', 4200),
  ('[email protected]', 9900),
  ('[email protected]', 1500);

And if you order by the ID, the values broadly follow creation time because the leading bytes encode the timestamp.

SELECT id, customer_email, created_at
FROM invoices
ORDER BY id DESC;

That does not mean id replaces every created_at column. Keep created_at. It is clearer, queryable as a timestamp, and part of the domain language most Rails, reporting, and support workflows expect. UUIDv7 simply gives the primary key index a more write-friendly shape than UUIDv4.

Why UUIDv7 feels like a better default

The usual integer primary key has a beautiful property: locality. Insert row 101 after row 100 and the index knows roughly where to put it. That is one reason integer IDs feel fast and boring.

The usual UUIDv4 primary key has a beautiful property too: opacity. Looking at one ID does not tell you the next one. It does not advertise whether your app has 12 invoices or 12 million invoices.

The trade-off has always been that UUIDv4 gives the database less locality. Random values scatter writes across the index. For many applications that is fine. For heavier write paths, large tables, or systems where primary-key indexes stay hot all day, randomness can show up as page churn, cache misses, and less predictable performance.

UUIDv7 is the useful compromise. It gives the database a time-ordered prefix and gives the application an identifier that still does not behave like a simple counter.

Do not mistake time-ordered for secret

UUIDv7 is not a secret token. It includes time information by design. PostgreSQL even gives you uuid_extract_timestamp() for version 1 and version 7 UUIDs.

That matters when you choose where to use it. A UUIDv7 is a good primary key candidate. It is a poor password reset token. It is a poor invitation secret. It is not a substitute for authorization. If a user should not see an invoice, the controller, policy, scope, or query must enforce that, no matter how unguessable the URL looks.

Use UUIDv7 to avoid easy enumeration and improve database locality. Use real secrets for secrets.

Rails migrations get simpler

In a Rails app on PostgreSQL 18, you can model this directly in a migration with a database default.

class CreateInvoices < ActiveRecord::Migration[8.0]
  def change
    create_table :invoices, id: :uuid, default: -> { "uuidv7()" } do |t|
      t.string :customer_email, null: false
      t.integer :total_cents, null: false

      t.timestamps
    end
  end
end

The important choice is where the ID is generated. Letting PostgreSQL generate the value keeps every writer consistent. Rails, background jobs, data imports, admin scripts, and direct SQL maintenance paths all get the same behavior.

If your application already uses UUIDv4, this is not an emergency rewrite. There is rarely a good reason to churn every primary key in an existing production system just because a nicer generator exists. The interesting places are new tables, new applications, event-style tables, audit trails, append-heavy records, and migrations where you already planned to change identifier strategy.

What I would still keep explicit

I would still keep created_at and index it when the application asks time-based questions directly. UUIDv7 can sort in creation order, but a timestamp column explains intent better and supports normal date ranges, reporting, retention, and partitioning decisions.

I would still use slugs for public, human-facing URLs. UUIDs are fine for internal tools, APIs, admin screens, and callback URLs, but they are not pretty marketing surfaces. If the page is meant to rank, be shared, or be remembered, give it a slug and treat the UUID as infrastructure.

I would still review authorization as if every ID were guessable. UUIDs reduce enumeration pressure. They do not make access control optional.

And I would still benchmark the paths that matter. UUIDv7 has a better shape for indexes than UUIDv4, but your workload, fillfactor, table size, indexes, cache behavior, and write patterns decide what "better" means in production.

A practical default, not a religion

PostgreSQL 18's uuidv7() is one of those features that feels boring in the best possible way. It lets the database own identifier generation. It makes UUID primary keys less hostile to indexes. It keeps application URLs from becoming obvious counters. And it does all of that with one default expression.

For greenfield PostgreSQL 18 apps, I would reach for UUIDv7 before UUIDv4 for primary keys unless I had a specific reason not to. For existing apps, I would adopt it at the edges first: new append-heavy tables, new bounded contexts, new APIs, and places where leaking sequence shape has always felt a little uncomfortable.

That is a nice kind of progress. Not a new architecture. Not a framework migration. Just one less trade-off in a place every application touches.

Happy uuid-ing!