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 gives you two ways to constrain a column to a fixed vocabulary: CREATE TYPE ... AS ENUM and a reference (lookup) table with a FK. They overlap, but they're not interchangeable. Pick deliberately.
Enum:
CREATE TYPE order_status AS ENUM (
'draft', 'submitted', 'approved', 'shipped', 'delivered', 'cancelled'
);
CREATE TABLE orders (
...,
status order_status NOT NULL DEFAULT 'draft'
);Reference table:
CREATE TABLE order_status (
status type_name PRIMARY KEY,
sort_order smallint NOT NULL,
is_terminal boolean NOT NULL
);
INSERT INTO order_status VALUES
('draft', 10, FALSE),
('submitted', 20, FALSE),
('approved', 30, FALSE),
('shipped', 40, FALSE),
('delivered', 50, TRUE),
('cancelled', 99, TRUE)
ON CONFLICT DO NOTHING;
CREATE TABLE orders (
...,
status type_name NOT NULL DEFAULT 'draft' REFERENCES order_status(status)
);If in doubt, use a reference table. The flexibility is worth the small overhead of a join when you need extra metadata.
Strengths:
Limits:
ALTER TYPE order_status ADD VALUE 'partially_shipped' BEFORE 'shipped') but cannot be done inside a transaction in older Postgres versions (works in v12+ in many cases, but check)ALTER TYPE ... DROP VALUE. You have to rename the type, create a new one, migrate columns, drop the old typeALTER TYPE ... RENAME VALUEStrengths:
SELECT * FROM order_status WHERE is_terminal)Limits:
DELETE FROM order_status WHERE status = 'shipped' and break the world (mitigation: RLS or REVOKE DELETE ... FROM PUBLIC)Enum → reference table:
-- 1. Create the reference table and seed
CREATE TABLE order_status (status type_name PRIMARY KEY, ...);
INSERT INTO order_status(status)
SELECT unnest(enum_range(NULL::order_status))::text;
-- 2. Add new column on orders
ALTER TABLE orders ADD COLUMN status_new type_name;
UPDATE orders SET status_new = status::text;
-- 3. Swap columns, add FK
ALTER TABLE orders DROP COLUMN status;
ALTER TABLE orders RENAME COLUMN status_new TO status;
ALTER TABLE orders ADD CONSTRAINT orders_classified_by_status
FOREIGN KEY (status) REFERENCES order_status(status);
-- 4. Drop the old enum
DROP TYPE order_status;Reference table → enum: rarely worth doing. If you find yourself going this direction, the table probably wasn't earning its keep.
For vocabularies where you want enum-level type safety and extra metadata, keep both:
CREATE TYPE order_status AS ENUM (...);
CREATE TABLE order_status_meta (
status order_status PRIMARY KEY,
display_name text NOT NULL,
sort_order smallint NOT NULL,
is_terminal boolean NOT NULL
);The column on orders is typed as the enum; the meta table is for lookups. Cost: every new enum value requires both an ALTER TYPE and an INSERT into the meta table.
This is reasonable for status machines (queue_status, order_status) where the vocabulary is genuinely closed but you still want metadata.
| Vocabulary | Pick | Why |
|---|---|---|
account_type ('savings', 'checking', ...) | Reference table | Type discriminator with FK from subtype tables |
order_status ('draft', 'shipped', ...) | Hybrid or enum | Closed state machine, need ordering, want metadata |
queue_status ('pending', 'in_progress', ...) | Reference table or enum | Used by many tables, closed set |
country_code (ISO 3166) | Reference table | Need name, region, currency, etc. |
phone_type ('mobile', 'home', 'work') | Enum | Tiny closed set, just a label |
feature_flag_name | Reference table | Runtime additions, metadata, possibly RLS-scoped |
audit_action ('INSERT', 'UPDATE', 'DELETE') | Enum | Triple-locked: closed set, just labels, never changes |
permission_role | Reference table | FK from user_role; rich metadata |
evals
scenario-1
scenario-2
references