Use when writing or reviewing PostgreSQL/PL-pgSQL, designing table schemas, writing functions and procedures, building migrations, defining domains, or architecting a Postgres application database. Also use when writing RAISE EXCEPTION patterns, BEFORE/AFTER triggers for cross-table constraints, base/subtype hierarchies, composite key designs, row-level security policies, or idempotent DDL scripts. If you are touching Postgres for an application database, use this skill. PostgreSQL-specific — examples will not run on other engines.
84
89%
Does it follow best practices?
Impact
100%
1.72xAverage score across 2 eval scenarios
Passed
No known issues
Postgres supports UUIDs natively as a 16-byte type. The questions are: which UUID version, when to use UUIDs vs bigint, and how to keep index performance acceptable.
| Aspect | bigint (or composite key) | UUID |
|---|---|---|
| Size | 8 bytes | 16 bytes |
| Index efficiency | High (small, sequential) | Lower (larger; v4 is random) |
| Distributed generation | Requires coordination (sequences) | None needed |
| Public exposure | Leaks sequence (count, age) | Opaque |
| Readability | High (customer 42) | Low (f47ac10b-58cc-...) |
| Collision risk | None | Effectively zero (2^122) |
Don't pick UUIDs because they "feel modern." Pick them when you need:
For internal data with a single writer, bigint (or composite keys per the skill's conventions) is faster, smaller, and easier to debug.
The standard random UUID. Generated by gen_random_uuid() from pgcrypto:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE document (
document_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
...
);Index fragmentation gotcha. B-tree indexes on UUID v4 fragment badly because each insert lands at a random position in the index. Compared to a sequential bigint, you get:
For low-volume tables this is fine. For high-write tables (event logs, audit, queues), the cost shows up under load.
UUID v7 (RFC 9562) encodes a millisecond timestamp in the leading bits, so v7 values sort roughly by creation time. This makes the index behave like a bigint — new IDs land at the right side of the B-tree, no fragmentation:
-- Hand-rolled v7 generator (since gen_random_uuid is v4)
CREATE OR REPLACE FUNCTION fn_gen_uuid_v7()
RETURNS uuid LANGUAGE plpgsql AS $$
DECLARE
v_ts bytea := int8send(EXTRACT(EPOCH FROM clock_timestamp())::bigint * 1000);
v_rand bytea := gen_random_bytes(10);
BEGIN
-- Layout: 48 bits timestamp + 4 bits version + 12 bits random + 2 bits variant + 62 bits random
RETURN (
SUBSTRING(v_ts FROM 3 FOR 6) ||
SET_BYTE('\x00'::bytea, 0, ((127 & 7) | 112)) -- version 7
-- ... etc; in practice use an extension
)::uuid;
END;
$$;In practice use an extension or library — pg_uuidv7 is one option. Some managed Postgres hosts (Supabase) ship it.
Tradeoff: the timestamp leaks creation time. For most apps that's fine or even useful; for adversarial settings where timing is sensitive, prefer v4.
Get the best of both: bigint PK for internal joins, UUID for the public boundary:
CREATE TABLE customer (
customer_no customer_no PRIMARY KEY, -- internal bigint
public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
...
);customer_no — small, fast, friendly to indexespublic_id — opaque, leak-freepublic_id gives O(log n) lookup at the API boundaryThis is the most common production pattern. The cost is one extra column (16 bytes per row) and a UNIQUE index.
uuid type is 16 bytes. Never store UUIDs as text (37 bytes plus overhead) or varchar(36).bigint PK costs ~3GB. Plan accordingly.evals
scenario-1
scenario-2
references