Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

PostgreSQL UUID — the practical guide

On this page
  1. TL;DR
  2. Built-in generators by version
  3. Why v7 over v4 for primary keys
  4. Indexing
  5. Storage size
  6. Casting
  7. Generated columns: extracting v7 timestamps
  8. Foreign keys
  9. Sharding / multi-tenant
  10. Performance tips
  11. Common pitfalls
  12. Cheat sheet
  13. Try the tools

PostgreSQL has a native uuid data type and built-in generators. For new tables, use the uuid type with uuidv7() (PG 18+) or gen_random_uuid() (PG 13+). Avoid the older uuid-ossp extension unless you specifically need v3 or v5.

TL;DR

-- PostgreSQL 18+ (October 2025): time-ordered, the new default
CREATE TABLE orders (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    created_at timestamptz NOT NULL DEFAULT now(),
    ...
);

-- PostgreSQL 13–17: random UUIDs (built-in, no extension needed)
CREATE TABLE orders (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    ...
);

That’s the whole pattern for 95% of cases. Read on for when to deviate.

Built-in generators by version

PG versionGeneratorVersionNotes
18+uuidv7()v7Recommended for new tables
18+uuidv4()v4Identical output to gen_random_uuid()
13+gen_random_uuid()v4Built-in, no extension
< 13gen_random_uuid()v4Requires pgcrypto extension
anyuuid_generate_v1()v1Requires uuid-ossp
anyuuid_generate_v3(ns, name)v3Requires uuid-ossp
anyuuid_generate_v5(ns, name)v5Requires uuid-ossp

Note: uuidv7() was added in PostgreSQL 18. For 13–17, generate v7 in your application code (see Python, JavaScript, C#) and insert the value.

Why v7 over v4 for primary keys

v4 UUIDs are random. As primary keys, they cause B-tree page splits all over the index — every insert lands on a different page, every commit dirties multiple pages. On a 100M-row table, this can be 2–5× slower than a serial integer.

v7 UUIDs have a millisecond timestamp at the front. Sequentially-generated v7s land on the rightmost B-tree leaf, just like a serial integer. Same insert performance, none of the coordination issues. See UUID as primary key for the full breakdown.

-- Migrate a v4 table to v7 for new rows (existing rows keep their v4 IDs)
ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();

Indexing

The default B-tree index works fine for both v4 and v7. For very large tables (>100M rows), consider:

-- Hash index — smaller than B-tree, equality lookups only
CREATE INDEX orders_id_hash ON orders USING hash (id);

-- BRIN — tiny, only useful if data is physically clustered by ID
-- (works for v7 because of the time-ordered prefix, not for v4)
CREATE INDEX orders_id_brin ON orders USING brin (id);

For most workloads, the B-tree primary key index is enough.

Storage size

TypeBytesNotes
uuid (native)16Always use this
text / varchar(36)~372× the storage, slower lookups
bytea (16 bytes)16 + overheadFunctionally same as uuid, but no validation

Don’t store UUIDs as text. It’s twice the storage, slower to compare, and doesn’t validate format.

Casting

-- text → uuid (validates format, throws on invalid)
SELECT '12fbc73a-9e9d-44b1-9e6d-1d3e7c8a0f31'::uuid;

-- uuid → text (canonical lowercase form)
SELECT id::text FROM orders;

-- bytes ↔ uuid
SELECT encode(uuid_send(id), 'hex') FROM orders;  -- 32-char hex
SELECT uuid_recv('\x12fbc73a9e9d44b19e6d1d3e7c8a0f31'::bytea);

Generated columns: extracting v7 timestamps

If your primary key is v7, you can compute the creation time without storing a separate created_at:

ALTER TABLE orders
ADD COLUMN created_from_id timestamptz GENERATED ALWAYS AS (
    to_timestamp(
        ('x' || lpad(replace(id::text, '-', '')::text, 12))::bit(48)::bigint::numeric / 1000.0
    )
) STORED;

For most cases it’s cleaner to keep an explicit created_at column — the storage cost is trivial and queries against it are clearer. But for audit / forensics, knowing every v7 ID has its time embedded is useful.

Foreign keys

UUIDs as foreign keys work identically to any other type:

CREATE TABLE order_items (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    order_id uuid NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    ...
);

For very wide foreign key chains (e.g. a 4-level deep tree of UUID FKs), the 16-byte-per-FK overhead can add up. At that point consider whether the join pattern is right, not whether to abandon UUIDs.

Sharding / multi-tenant

UUIDs solve the “two databases, no coordination” problem cleanly. Two PostgreSQL instances on different hosts can both generate uuidv7() and insert into the same logical table without colliding.

For sharded systems (Citus, partitioned tables), UUIDs let any shard generate IDs without consulting the others — no central sequence service needed.

Performance tips

Common pitfalls

Cheat sheet

GoalSQL
New table, modern (PG 18+)id uuid PRIMARY KEY DEFAULT uuidv7()
New table, PG 13–17id uuid PRIMARY KEY DEFAULT gen_random_uuid()
Cast text to uuid'...'::uuid
Validate formatWHERE x::uuid IS NOT NULL (errors on invalid)
Storage typeuuid — never text

Try the tools