Postgres Schema Design
Schema decisions are the most expensive to reverse. Take the time to model correctly, write reversible migrations, and prove index choices with EXPLAIN.
Step 1 — Establish context
- Identify the toolchain:
prisma/schema.prisma,drizzle/+drizzle.config.*,supabase/migrations/, or plainmigrations/*.sql. Follow its conventions for naming and for how migrations are generated. - Dump the current schema so you design against reality:
pg_dump --schema-only,prisma db pull, orsupabase db dump --schema public. - Ask (or infer from the code) the three questions that decide the design: expected row counts per table, the top five read queries, and who writes (single service, many tenants, end users through PostgREST).
Step 2 — Model
- Keys. Primary key
idasbigint generated always as identityfor internal tables; UUIDv7 (uuidwith a v7 generator) when ids are exposed publicly or generated client-side. Avoid random UUIDv4 as clustered keys on hot tables. - Names.
snake_case, singular or plural consistently with the existing schema, foreign keys as<table>_id, timestampscreated_at/updated_atastimestamptz not null default now(). - Types.
textovervarchar(n);numericfor money (neverfloat);timestamptznevertimestamp;jsonbonly for genuinely schemaless data, and pull hot fields out into columns. - Constraints are documentation that cannot rot. Add
not nullby default,checkconstraints for enums and ranges,uniquefor natural keys, andreferences ... on deletewith a deliberate choice (cascadefor owned children,restrictfor shared references). - Enums. Prefer a
check (status in (...))or a lookup table over a Postgresenumtype unless the tool handles enum migrations well; renaming enum values is painful. - Soft delete. Only if required; add
deleted_at timestamptzand a partial indexwhere deleted_at is null. - Multi-tenancy. Put
org_idon every tenant-scoped table, include it in composite indexes first, and enforce it with RLS.
Step 3 — Index deliberately
- Every foreign key column gets an index unless the table is tiny.
- Composite indexes follow the query: equality columns first, then range/sort columns.
(org_id, created_at desc)serves "latest items for a tenant". - Use partial indexes for status filters (
where status = 'open'),ginforjsonbcontainment and array membership,ginwithpg_trgmforILIKE '%term%', and covering indexes (include (...)) for index-only scans. - Do not add an index you cannot tie to a query. Each one slows writes.
- Verify:
explain (analyze, buffers) <query>before and after; look forSeq Scanon large tables andSortnodes that an index would remove.