Postgres Schema Design skill for Codex
Make invalid rows impossible to insert. Codex designs Postgres tables from their access patterns and pushes business rules into the database: bigint identity or uuid keys, timestamptz, numeric or minor-unit money, scoped unique keys, exclusion constraints for overlaps, and explicit ON DELETE on every foreign key. It also reviews ORM-generated DDL for silent type mistakes and documents which rules stay in application code.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- postgres-schema-design Installs to ~/.agents/skills/postgres-schema-design/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install postgres-schema-design The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project postgres-schema-design Installs to .agents/skills/postgres-schema-design/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/postgres-schema-design.zipunzip -o postgres-schema-design.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) postgres-schema-design PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $postgres-schema-design, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs PostgreSQL schemas that keep data correct under concurrent writes - column types (bigint identity, uuid, timestamptz, numeric, text), primary and foreign keys with explicit ON DELETE, CHECK, UNIQUE and exclusion constraints, naming conventions, normalization and when to denormalize or use jsonb. Reviews CREATE TABLE statements and ORM models for type and constraint mistakes. Use when creating tables, modeling a new feature, reviewing a schema or migration, or choosing between enum, lookup table and check constraint.
Use it when
- Modeling tables for a new feature
- Reviewing CREATE TABLE statements or ORM models before the first migration
- Deciding between enum, check constraint, lookup table or jsonb
- Adding tenant scoping or uniqueness rules
Use something else when
- The table already exists in production and you need to change it safely: use zero-downtime-migrations
- The data is document-shaped and lives in MongoDB: use mongodb-schema-design
How to check the result
Insert rows that break each CHECK, UNIQUE and foreign key rule and confirm Postgres rejects them, then run the main access-pattern queries with EXPLAIN on seeded data.
The mistake it prevents
Using timestamp without time zone and float for money because the ORM defaulted to them, then discovering it after millions of rows exist.
A good schema makes invalid data impossible to store, not merely unlikely. Every business rule the database can check (presence, uniqueness, ranges, relationships) is a constraint, because application validation is bypassed by scripts, backfills, races and the next service that writes to the table.
Before designing
- Read
AGENTS.mdand existing migrations: naming style, singular vs plural tables, id type, timestamp columns, ORM (Prisma, Django, Rails, SQLAlchemy, Drizzle). Match the house conventions unless they are actively harmful; flag those separately. - Write down the access patterns: the queries this table must serve, expected row count in a year, write rate, and who deletes rows. Schema follows usage.
- Identify the natural key (what makes a row unique in the business sense) even when you use a surrogate primary key. It becomes a UNIQUE constraint.
Type choices
| Data | Use | Avoid |
|---|---|---|
| Surrogate key | bigint GENERATED ALWAYS AS IDENTITY | serial (legacy, ownership quirks), int (overflows at 2.1B) |
| Public or distributed id | uuid; generate v7 (time-ordered) with uuidv7() in PG 18 or in the app | Random v4 as the clustered-ish PK on huge insert-heavy tables (index locality suffers) |
| Strings | text, plus CHECK (char_length(x) <= n) when a limit is a real rule | varchar(n) as a habit, char(n) (pads with spaces) |
| Point in time | timestamptz | timestamp without time zone for events; it silently drops the offset |
| Calendar date (birthday, due date) | date | timestamptz at midnight |
| Money | numeric(19,4) or integer minor units (bigint cents) plus a currency column | float/double, the money type (locale-dependent) |
| Booleans | boolean NOT NULL DEFAULT false | Nullable booleans (three states nobody handles) |
| Fixed small set of states | text + CHECK (status IN (...)), or a lookup table with FK | Native enum if values may be removed or reordered later |
| Semi-structured, rarely filtered attributes | jsonb | json (no indexing, keeps duplicates), jsonb for core relational fields |
| Ranges (bookings, validity) | tstzrange, daterange + exclusion constraint | Two columns with no overlap guarantee |
| IP addresses | inet / cidr | text |
Enum vs check vs lookup table: native enums are compact but values cannot be dropped
without recreating the type, and ALTER TYPE ... ADD VALUE cannot be used in the same
transaction that adds it. A CHECK constraint is easiest to change. Choose a lookup table
when states carry attributes (label, sort order, is_terminal) or are edited by users.
Constraints checklist
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL REFERENCES tenants (id) ON DELETE RESTRICT,
customer_id bigint NOT NULL REFERENCES customers (id) ON DELETE RESTRICT,
number text NOT NULL,
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'issued', 'paid', 'void')),
currency char(3) NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
total_minor bigint NOT NULL CHECK (total_minor >= 0),
issued_at timestamptz,
paid_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT invoices_tenant_number_key UNIQUE (tenant_id, number),
CONSTRAINT invoices_paid_after_issued CHECK (paid_at IS NULL OR paid_at >= issued_at),
CONSTRAINT invoices_issued_has_date CHECK (status = 'draft' OR issued_at IS NOT NULL)
);
CREATE INDEX invoices_customer_id_idx ON invoices (customer_id);
(char(3) is acceptable here because ISO currency codes are exactly three letters.)
NOT NULLby default. Each nullable column needs a reason: “unknown” and “not applicable” are different, and NULL conflates them.- Every foreign key has an explicit
ON DELETE:RESTRICTfor financial or audit data,CASCADEonly for true ownership (order -> order_items),SET NULLwhen the link is optional. Index the referencing column (Postgres does not). - Uniqueness is scoped: in multi-tenant tables, unique keys include
tenant_id. - Unique with nullable columns: NULLs are distinct by default, so two rows with
(42, NULL)both insert. UseUNIQUE NULLS NOT DISTINCT(PG 15+) or a partial unique index (WHERE deleted_at IS NULL) for soft-deleted rows. - No overlapping bookings:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
updated_atdoes not maintain itself. Use a trigger or make the ORM own it, and say which.
Naming
snake_case, lowercase, no quoted identifiers (quoted"UserId"must be quoted forever).- Pick plural or singular tables and stay consistent with the repo.
- Foreign keys:
<referenced_singular>_id(customer_id). - Name constraints and indexes explicitly:
<table>_<cols>_key(unique),<table>_<cols>_idx,<table>_<cols>_fkey,<table>_<rule>for checks. Default generated names differ between environments created in different ways and make later migrations brittle. - Avoid reserved words as names (
user,order,group,end);users,ordersare fine. - Timestamps end in
_at, dates in_onor_date, booleans start withis_/has_.
Normalization and when to break it
- Start in third normal form: each fact stored once. Repeated groups (
phone1,phone2,phone3) become a child table; comma-separated ids become a join table. - Denormalize only for a measured read path, and make the copy maintainable: a generated column, a trigger, or a materialized view refreshed on a schedule. Document which column is the source of truth.
- Use
jsonbfor attributes that vary per row and are read whole (provider payloads, user preferences). If you filter, join or aggregate on a key, it belongs in a column ($postgres-jsonb). - Polymorphic associations (
commentable_type,commentable_id) cannot have a foreign key. Prefer separate nullable FK columns plus a CHECK that exactly one is set:CHECK (num_nonnulls(post_id, photo_id) = 1). - Soft delete (
deleted_at) spreadsWHERE deleted_at IS NULLeverywhere and breaks unique constraints. Prefer moving rows to an archive table unless undo is a real requirement.
Multi-tenant tables
Put tenant_id on every tenant-owned table, include it in unique keys and as the
leading column of most indexes, and consider composite foreign keys
(tenant_id, customer_id) REFERENCES customers (tenant_id, id) so a row can never
point at another tenant’s data. For enforcement in the database see
$postgres-row-level-security.
Gotchas
- Changing a column type later usually rewrites the table under an exclusive lock.
Get
bigintandtimestamptzright on day one ($zero-downtime-migrations). DEFAULT now()records transaction start, so all rows in one transaction share it. Useclock_timestamp()when you need wall-clock time per row.- Generated columns:
GENERATED ALWAYS AS (...) STOREDmust use immutable expressions; PG 18 addsVIRTUAL(computed on read, the new default when neither is given). - ORMs default to their own choices (Prisma
DateTimemaps totimestamp(3)without time zone unless annotated@db.Timestamptz; Railsdatetimemaps totimestampunless the PostgreSQL adapter’sdatetime_typeis set to:timestamptz). Check the generated SQL, not the model file. - Column order matters little for correctness, but grouping fixed-width columns (bigint, timestamptz) before variable-width ones reduces padding on very large tables.
Verification
- Generated DDL inspected (
pg_dump --schema-only -t <table>or the migration SQL) - Every column has a deliberate type and nullability; every FK has ON DELETE and an index
- Natural keys enforced with UNIQUE, scoped by tenant where relevant
- Constraint violations tested: insert a row that breaks each CHECK/UNIQUE/FK and confirm the database rejects it (a quick SQL script or an integration test)
- Access-pattern queries run against the schema with EXPLAIN on seeded data
Output template
## Schema: <feature>
**Tables:** <name>: <purpose, expected rows/year, write rate>
**DDL:** <CREATE TABLE statements with named constraints and indexes>
**Rules enforced in DB:** <list each constraint and the business rule it encodes>
**Rules left to the app (and why):** <list>
**Access patterns covered:** <query -> index>
**Open questions:** <deletion policy, retention, tenancy> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Postgres Schema Design"
short_description: "Make invalid rows impossible to insert"
default_prompt: "$postgres-schema-design Design the Postgres tables for invoices, line items and payments" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.