
PostgreSQL Table Design provides guidance on schema design best practices for PostgreSQL databases, covering primary keys, normalization, data types, indexing strategies, constraints, and PostgreSQL-specific behaviors. It serves database architects and developers who need to design or review PostgreSQL schemas by helping them avoid common pitfalls like missing foreign key indexes, premature denormalization, and misunderstandings about PostgreSQL's MVCC storage model and type coercion rules. The skill solves the problem of creating maintainable, performant database schemas that follow PostgreSQL conventions and leverage its specific features effectively.
$ npx -y skills add wshobson/agents --skill postgresql-table-design --agent claude-codeInstalls into .claude/skills of the current project.
The rules and decision points for a PostgreSQL schema. The full data-type catalog, workload
patterns (update-heavy, insert-heavy, upsert, schema evolution), extensions, JSONB indexing,
and worked DDL examples are in references/details.md; open it when a section below points there.
BIGINT GENERATED ALWAYS AS IDENTITY; use UUID only when global uniqueness/opacity is needed.NUMERIC for exact decimal arithmetic).snake_case.UNIQUE NULLS NOT DISTINCT (...) (PG15+) to restrict to one NULL.NUMERIC(2,0) fails, unlike databases that silently truncate or round.CLUSTER is a one-off reorganization, not maintained on later inserts.BIGINT GENERATED ALWAYS AS IDENTITY; UUID for distributed or opaque IDs, generated with uuidv7() (PG18+) or gen_random_uuid().BIGINT unless storage is critical; DOUBLE PRECISION over REAL; NUMERIC(p,s) for money and exact decimals.TEXT, with CHECK (LENGTH(col) <= n) when a limit is needed; BYTEA for binary. Case-insensitive lookups: expression index on LOWER(col), or CITEXT when a constraint must be case-insensitive.TIMESTAMPTZ, DATE, INTERVAL. now() is transaction start; clock_timestamp() is wall clock.BOOLEAN NOT NULL unless tri-state is required.CREATE TYPE ... AS ENUM only for small, stable sets; evolving business values get TEXT + CHECK or a lookup table.references/details.md.| Avoid | Use instead |
|---|---|
timestamp (without time zone) | timestamptz |
char(n), varchar(n) | text (+ CHECK on length if needed) |
money | numeric |
timetz | timestamptz |
timestamptz(0) or any precision | timestamptz |
serial | generated always as identity |
ON DELETE/UPDATE (CASCADE, RESTRICT, SET NULL, SET DEFAULT). Index the referencing column. Use DEFERRABLE INITIALLY DEFERRED for circular dependencies checked at commit.NULLS NOT DISTINCT (PG15+). Prefer NULLS NOT DISTINCT unless duplicate NULLs are wanted.NOT NULL: price NUMERIC NOT NULL CHECK (price > 0).EXCLUDE USING gist (room_id WITH =, booking_period WITH &&) stops double-booking. Needs a GiST-capable type.=, <, >, BETWEEN, ORDER BY).WHERE a = ? AND b > ? uses (a,b); WHERE b = ? does not). Most selective columns first.CREATE INDEX ON tbl (id) INCLUDE (name, email) for index-only scans.CREATE INDEX ON tbl (user_id) WHERE status = 'active'.CREATE INDEX ON tbl (LOWER(email)); the query must use the same expression.PARTITION BY RANGE (created_at); TimescaleDB automates it with retention and compression), LIST for discrete values, HASH for even distribution without a natural key.CHECK constraints; declarative partitioning (PG10+) creates them for you.CREATE TABLE users (
user_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON users (LOWER(email));
CREATE INDEX ON users (created_at);
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','PAID','CANCELED')),
total NUMERIC(10,2) NOT NULL CHECK (total > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON orders (user_id);
CREATE INDEX ON orders (created_at);
-- JSONB attributes with a generated, indexable scalar
CREATE TABLE profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(user_id),
attrs JSONB NOT NULL DEFAULT '{}',
theme TEXT GENERATED ALWAYS AS (attrs->>'theme') STORED
);
CREATE INDEX profiles_attrs_gin ON profiles USING GIN (attrs);
references/details.md holds the material this file only names:
TEMPORARY, UNLOGGED) and row-level security.pg_trgm, citext, timescaledb, postgis, pgvector, and more).jsonb_path_ops and extracted B-tree columns.Sponsors