../

DB design & schemas

Designing relational schemas for PostgreSQL 18 apps: keys, normal forms, relationship and hierarchy patterns, history, multi-tenancy, migrations and Drizzle. SQL syntax, indexes and locking details live in PostgreSQL.

Modeling process

StepOutput
1. List the nouns the business usesentities: user, org, project, invoice
2. Attributes per entityrequired? unique? derived? who may change it?
3. Relationships and cardinality1:1, 1:N, M:N; optional or mandatory on each side
4. Invariantsrules that must always hold: they become constraints
5. Access patternstop reads and writes with their filters and sort orders: they become indexes
6. Lifecyclecreate, edit, archive, delete, and whether history matters
7. Normalize to 3NFthen denormalise only where a measured access pattern needs it

Say each relationship as two sentences, one per direction, and map the words to columns.

SentenceCardinalityImplementation
"each order has exactly one customer"mandatory oneorders.customer_id NOT NULL REFERENCES customers
"a task may have one assignee"optional onenullable FK
"a customer has zero or more orders"optional manyFK lives on the many side
"a user has at most one profile"1:1FK that is also the PK (or UNIQUE) on the dependent table
"students take many courses, courses have many students"M:Njunction table with a composite PK

Keys

KindExampleProsCons
naturalemail, ISO country_code, ISBNmeaningful, no extra columnchanges (emails do), may be personal data, wide composite FKs
surrogateid bigint identity, id uuidstable, compact, never needs updatingneeds a separate UNIQUE on the natural key

Default: surrogate primary key plus a UNIQUE constraint on the natural key. Natural keys are fine for stable codes (currency char(3)) and for junction tables (PRIMARY KEY (post_id, tag_id)).

Id typeSizeProsCons
bigint GENERATED ALWAYS AS IDENTITY8 Bsmallest indexes and FKs, sequential insertsguessable, leaks row counts, only the DB can mint ids
uuid DEFAULT uuidv7() (18+)16 Btime-ordered, so inserts stay at the index's right edge; mint anywhere (Bun.randomUUIDv7())bigger indexes; the id reveals its creation time
uuid v4 (gen_random_uuid())16 Bfully random, reveals nothingrandom inserts scatter across the index: poor locality on big tables
prefixed text (usr_01J...)20–30 Breadable in logs and URLstext comparisons, larger

Two common setups: uuid v7 keys everywhere (simple, client-generated ids, easy merges between databases), or bigint keys internally plus a public_id uuid exposed in URLs and APIs.

Normalization

"Every non-key column depends on the key, the whole key, and nothing but the key."

FormRuleViolationFix
1NFatomic values, no repeating groupstags = 'a,b,c'; phone1, phone2, phone3child table (or a typed array you never join on)
2NF1NF, and non-key columns depend on the whole composite keyorder_items(order_id, product_id, product_name): name depends only on product_idkeep name in products
3NF2NF, and no non-key column depends on another non-key columnorders(customer_id, customer_email)read the email from customers
BCNFevery determinant is a candidate keylessons(student, subject, teacher) where each teacher teaches one subjectsplit into teachers(teacher, subject) and lessons(student, teacher)
4NFno independent multi-valued facts in one tableperson_skills_langs(person, skill, language)person_skills and person_languages
Denormalise whenHowKeep honest by
a count or total is read far more than writtenposts.comment_countupdating it in the same transaction or a trigger; a job that recomputes it
the value is a snapshot, not a referenceorder_items.unit_price_cents at purchase timenothing: it's a different fact, not a duplicate
RLS or partitioning needs a column without a joincopy org_id onto child tablescomposite FKs that include org_id
reports scan millions of rowsmaterialized view or a warehouseREFRESH MATERIALIZED VIEW CONCURRENTLY on a schedule
a derived value is filtered ongenerated columnGENERATED ALWAYS AS (...) STORED

Relationships

One-to-one and one-to-many

-- 1:1: split rarely read or sensitive columns out
CREATE TABLE user_profiles (
  user_id    bigint PRIMARY KEY
             REFERENCES users (id) ON DELETE CASCADE,
  bio        text,
  avatar_url text
);
 
-- 1:N: FK on the many side, indexed
CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY
              PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers (id),
  total_cents bigint NOT NULL CHECK (total_cents >= 0),
  placed_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_idx
  ON orders (customer_id, placed_at DESC);

Many-to-many

CREATE TABLE enrollments (
  student_id  bigint NOT NULL
              REFERENCES students (id) ON DELETE CASCADE,
  course_id   bigint NOT NULL
              REFERENCES courses (id) ON DELETE CASCADE,
  enrolled_at timestamptz NOT NULL DEFAULT now(),
  grade       text,
  PRIMARY KEY (student_id, course_id)
);
-- the PK serves student lookups; add the reverse
CREATE INDEX enrollments_course_idx
  ON enrollments (course_id);

A junction table that collects attributes (role, dates, status) is an entity in its own right: give it a name like enrollments or memberships, not students_courses.

Self-referencing

CREATE TABLE employees (
  id         bigint GENERATED ALWAYS AS IDENTITY
             PRIMARY KEY,
  name       text NOT NULL,
  manager_id bigint
             REFERENCES employees (id) ON DELETE SET NULL,
  CHECK (manager_id <> id)
);
CREATE INDEX employees_manager_idx ON employees (manager_id);

Polymorphic associations

comments(commentable_type text, commentable_id bigint) can point at posts or photos, but no foreign key can enforce it: orphans and typos go unnoticed.

AlternativeShapeWhen
exclusive arcsone nullable FK per parent + CHECK (num_nonnulls(...) = 1)a few parent types
one table per parentpost_comments, photo_commentsparents rarely queried together
shared supertypecommentables(id); posts, photos and comments reference itmany parent types
CREATE TABLE comments (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  post_id  bigint REFERENCES posts (id) ON DELETE CASCADE,
  photo_id bigint REFERENCES photos (id) ON DELETE CASCADE,
  body     text NOT NULL,
  CHECK (num_nonnulls(post_id, photo_id) = 1)
);
CREATE INDEX comments_post_idx ON comments (post_id)
  WHERE post_id IS NOT NULL;
CREATE INDEX comments_photo_idx ON comments (photo_id)
  WHERE photo_id IS NOT NULL;

Hierarchies

PatternStoresRead a subtreeMove a subtreeIntegrity
adjacency listparent_idrecursive CTEupdate one rowFK
closure tableevery ancestor/descendant pair + depthone join, indexeddelete and reinsert the subtree's pairsFKs; maintained by app or trigger
materialized pathpath text like '/1/4/9/'LIKE '/1/4/%'rewrite the subtree's pathsnone built in
ltreepath ltree like 1.4.9path <@ '1.4' with a GiST indexUPDATE ... SET path = new || subpath(path, n)none built in
nested setslft, rgtrange queryrenumber half the tablefragile: avoid for data that changes

Start with an adjacency list and a recursive CTE (see PostgreSQL). Add a closure table when deep subtree reads dominate, or ltree for category-style trees queried by path.

CREATE EXTENSION IF NOT EXISTS ltree;
CREATE TABLE categories (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name text NOT NULL,
  path ltree NOT NULL UNIQUE   -- 'electronics.phones'
);
CREATE INDEX categories_path_gist
  ON categories USING gist (path);
 
-- subtree, ancestors, depth
SELECT * FROM categories WHERE path <@ 'electronics';
SELECT * FROM categories
WHERE path @> 'electronics.phones.android';
SELECT name, nlevel(path) AS depth FROM categories;

ltree labels allow letters, digits, _ and -: use slugs or ids, not display names.

Naming & constraints

ThingConvention
tablesplural snake_case: users, order_items (singular is fine too; pick one)
columnssnake_case, no table prefix: users.name, not users.user_name
primary keyid
foreign key<singular>_id, or a role name: author_id, manager_id
booleansis_active, has_mfa
times, datescreated_at for timestamptz, due_on for date
junction tablesa noun (memberships) or post_tags
constraints, indexesPostgres defaults: _pkey, _key (unique), _fkey, _check, _idx
avoidquoted "CamelCase", reserved words (user, order, group), abbreviations

Unquoted identifiers fold to lower case, so createdAt becomes createdat. Map to camelCase in the ORM instead.

ConstraintDocuments
NOT NULLthe default for every column; NULL should mean "unknown" or "not applicable", and you should know which
CHECKdomain rules: price_cents >= 0, ends_at > starts_at, status IN (...)
UNIQUEbusiness uniqueness, usually scoped: UNIQUE (org_id, slug)
REFERENCES ... ON DELETEownership and lifecycle
EXCLUDE / WITHOUT OVERLAPSno double bookings
DEFAULTthe normal case
CREATE DOMAINa reusable type with rules, e.g. positive_cents
ON DELETEEffectUse for
NO ACTION (default), RESTRICTrefuse to delete a referenced parentthings history points at: products, customers
CASCADEdelete the children tooowned rows: org to memberships, post to comments
SET NULLkeep the child, clear the linkoptional links: task to assignee
SET NULL (col) (15+)clear only some FK columnscomposite FKs that include org_id
SET DEFAULTreset to the column default"unassigned" placeholder rows

Fixed value sets

OptionChange it byGood for
CHECK (status IN ('draft', 'live'))drop and re-add the constraintshort, code-owned lists
CREATE TYPE status AS ENUM (...)ALTER TYPE ... ADD VALUE; values can be renamed but never removedstable, ordered states
lookup table + FK on its codeINSERT a rowdata-owned lists with labels, sort order, is_active
CREATE DOMAIN positive_cents AS bigint CHECK (VALUE >= 0);
 
CREATE TABLE order_statuses (
  code  text PRIMARY KEY,         -- 'pending', 'paid'
  label text NOT NULL,
  sort  int NOT NULL DEFAULT 0
);
INSERT INTO order_statuses (code, label)
VALUES ('pending', 'Pending'), ('paid', 'Paid');
 
ALTER TABLE orders
  ADD COLUMN status text NOT NULL DEFAULT 'pending'
    REFERENCES order_statuses (code),
  ADD COLUMN tax_cents positive_cents NOT NULL DEFAULT 0;

A text code as the lookup key means most queries need no join.

Time & history

SituationStore
moments (created, paid, sent)timestamptz
every rowcreated_at timestamptz NOT NULL DEFAULT now(), plus updated_at kept by a trigger
a user's zone for displayIANA name in text ('Europe/Berlin'), not an offset
future local events ("9:00 Berlin next March")local timestamp + zone name: zone rules can change before then
birthdays, due datesdate
validity periodststzrange / daterange + an exclusion or WITHOUT OVERLAPS key
History patternAnswersCost
audit log (trigger writes old/new jsonb)who changed what, whengeneric; one table for everything
history table per entity"what was the price on 3 March?"a copy of each version
temporal table (valid tstzrange)the current and past values in one tablequeries filter on valid @> now()
event sourcingfull replay; state is derivedbig design commitment
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE product_prices (
  product_id  bigint NOT NULL REFERENCES products (id),
  valid       tstzrange NOT NULL,
  price_cents bigint NOT NULL CHECK (price_cents >= 0),
  PRIMARY KEY (product_id, valid WITHOUT OVERLAPS) -- 18+
);
 
INSERT INTO product_prices VALUES
  (1, tstzrange('2026-01-01', '2026-07-01'), 1000),
  (1, tstzrange('2026-07-01', NULL), 1200);
 
SELECT price_cents FROM product_prices
WHERE product_id = 1 AND valid @> now();

Soft delete

ForAgainst
undo and "trash" featuresevery query needs WHERE deleted_at IS NULL (views or RLS help)
references to the row stay validunique constraints must become partial indexes
a record of what existedON DELETE CASCADE never fires; children linger
GDPR erasure still needs a hard delete

Alternatives: move deleted rows to an archive table in the same transaction, keep an audit log and hard delete, or use a real status when "archived" is a business state. The partial-index version is a recipe in PostgreSQL.

Multi-tenancy

PatternIsolationOperationsFits
shared tables + org_id (+ RLS)logical: a missing filter leaks data, RLS is the netone schema, one migration run, cross-tenant analytics easymost SaaS: many small tenants
schema per tenantnamespace via search_pathmigrations run N times; catalog bloat past a few thousand schemastens to hundreds of tenants, per-tenant tweaks
database per tenantstrong; per-tenant backup, restore, regionconnections and migrations times Nfew large or regulated tenants
hybridshared by default, dedicated DB for big customerstwo code paths for routingenterprise tiers

Rules for shared tables:

  • Put org_id NOT NULL on every tenant-owned table, children included, so RLS, indexes and partitions never need a join.
  • Scope uniqueness: UNIQUE (org_id, slug).
  • Lead composite indexes with org_id.
  • Use composite FKs so a child can't point at another tenant's parent.
  • Connect as a role that is neither the table owner nor BYPASSRLS.
CREATE TABLE projects (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  org_id uuid NOT NULL REFERENCES orgs (id),
  slug   text NOT NULL,
  UNIQUE (org_id, id),      -- target for composite FKs
  UNIQUE (org_id, slug)
);
 
CREATE TABLE tasks (
  id         bigint GENERATED ALWAYS AS IDENTITY
             PRIMARY KEY,
  org_id     uuid NOT NULL,
  project_id bigint NOT NULL,
  title      text NOT NULL,
  FOREIGN KEY (org_id, project_id)
    REFERENCES projects (org_id, id) ON DELETE CASCADE
);
CREATE INDEX tasks_org_project_idx
  ON tasks (org_id, project_id);

JSONB vs columns

Use columns when the data isUse jsonb when the data is
filtered, joined, sorted or aggregatedstored and returned whole
subject to constraints or FKsshaped differently per row (per-integration settings)
a known, stable shapesparse optional attributes
updated field by field, oftenowned by someone else (webhook payloads, API snapshots)
jsonb catchDetail
no FKs into JSONids inside a document can dangle
weak statisticsthe planner guesses selectivity of meta ->> 'x'
whole-document writeschanging one key rewrites the value (large docs are TOASTed)
validationCHECK only the basics; validate the shape with Zod in the app
CREATE TABLE integrations (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  org_id   uuid NOT NULL REFERENCES orgs (id),
  kind     text NOT NULL CHECK (kind IN ('slack', 'github')),
  settings jsonb NOT NULL DEFAULT '{}'
           CHECK (jsonb_typeof(settings) = 'object'),
  -- promote a hot key to a real, indexable column
  channel  text GENERATED ALWAYS AS
           (settings ->> 'channel') STORED
);
CREATE INDEX integrations_channel_idx
  ON integrations (channel);

Indexing for access patterns

Query shapeIndex
WHERE org_id = $1 ORDER BY created_at DESC LIMIT 20(org_id, created_at DESC)
WHERE a = $1 AND b > $2(a, b): equality columns first, then the range
WHERE lower(email) = $1UNIQUE (lower(email)) expression index
WHERE status = 'pending' (rare value)partial: (created_at) WHERE status = 'pending'
join or cascade through a FKindex the FK column
WHERE tags @> ARRAY['x']GIN on the array
WHERE meta @> '{"k": 1}'GIN jsonb_path_ops
WHERE name ILIKE '%ada%'GIN gin_trgm_ops (pg_trgm)
keyset pagination(created_at, id) matching the ORDER BY
time range on a huge append-only tableBRIN on the timestamp
read two columns by key, no heap visit(key) INCLUDE (a, b)

Workflow: list the top queries, EXPLAIN (ANALYZE) them, add the narrowest index that removes the waste, and delete indexes that pg_stat_user_indexes shows are never used. Details: PostgreSQL indexes.

Migrations

RuleWhy
versioned, append-only files in gitreproducible schema; never edit one that has run anywhere
one concern per migrationeasy to review and retry
DDL inside a transactionPostgres rolls back most DDL; CONCURRENTLY can't be in one
SET lock_timeout in each migrationa waiting ALTER blocks every query behind it
schema changes separate from big backfillsbackfills run in batches, outside the deploy
forward-only in production"down" migrations rarely undo data changes safely
test against a production-sized copylocks and rewrites only hurt at scale

Expand / contract

Example: rename users.name to full_name with old and new app versions running side by side.

PhaseDatabaseApp
1. expandadd nullable full_name; trigger copies name to full_namestill reads and writes name
2. backfillcopy existing rows in batcheswrites both
3. switchNOT NULL via NOT VALID + VALIDATEreads full_name, writes both
4. contractdrop the trigger and namestops writing name

Each phase ships as its own deploy.

ChangeSafe way
add columnnullable, or with a constant default: instant
add NOT NULLNOT VALID constraint, then VALIDATE (recipe in PostgreSQL)
add indexCREATE INDEX CONCURRENTLY, outside a transaction
add FK or CHECKADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT
add uniqueCREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... UNIQUE USING INDEX
change type, renameexpand/contract
drop columndeploy code that no longer reads it, then drop
add enum valueALTER TYPE ... ADD VALUE (not usable until committed)
Drizzle project layout
db/schema/index.ts           # re-exports every tableusers.tsorgs.tsmemberships.tsmigrations/            # drizzle-kit output, committed0000_init.sql0001_projects.sql0002_backfill_handles.sqlmeta/_journal.json  # applied order0000_snapshot.jsonindex.ts               # db clientseed.tsdrizzle.config.tspackage.json
drizzle.config.ts
import { defineConfig } from "drizzle-kit";
 
export default defineConfig({
  dialect: "postgresql",
  schema: "./db/schema/index.ts",
  out: "./db/migrations",
  casing: "snake_case",        // createdAt -> created_at
  dbCredentials: { url: process.env.DATABASE_URL ?? "" },
});
bunx drizzle-kit generate --name add_projects  # diff -> SQL
bunx drizzle-kit migrate      # apply pending migrations
bunx drizzle-kit push         # dev only: sync, no files
bunx drizzle-kit check        # detect conflicting files
bunx drizzle-kit studio       # browse data

Review every generated SQL file: generators emit plain CREATE INDEX and blocking ALTERs. Edit or hand-write the migration when a table is big.

ORMs & query builders

ToolSchema lives inStyleMigrations
Drizzle (0.45 stable, 1.0 in RC)TS filesSQL-shaped builder + relational queriesdrizzle-kit
Prisma (7)schema.prismagenerated client, high-level APIPrisma Migrate
Kyselythe database (types from kysely-codegen)typed query builderits own migrator or any tool
raw SQL (Bun.sql, postgres.js)the databasetagged templates, types by handany tool (dbmate, Atlas, sqitch)
db/index.ts
import { drizzle } from "drizzle-orm/bun-sql";
import { and, eq } from "drizzle-orm";
import * as schema from "./schema";
import { memberships, orgs, users } from "./schema";
 
export const db = drizzle({
  connection: process.env.DATABASE_URL ?? "",
  schema,
  casing: "snake_case",
});
 
export function orgsFor(userId: string) {
  return db
    .select({ org: orgs.name, role: memberships.role })
    .from(memberships)
    .innerJoin(orgs, eq(orgs.id, memberships.orgId))
    .where(eq(memberships.userId, userId));
}
 
export async function join(orgId: string, email: string) {
  return db.transaction(async (tx) => {
    const [user] = await tx
      .select().from(users).where(eq(users.email, email));
    if (!user) throw new Error(`no user ${email}`);
    await tx.insert(memberships)
      .values({ orgId, userId: user.id })
      .onConflictDoNothing();
    return tx.select().from(memberships).where(and(
      eq(memberships.orgId, orgId),
      eq(memberships.userId, user.id),
    ));
  });
}
prisma/schema.prisma (Prisma 7)
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}
datasource db {
  provider = "postgresql"
}
model Membership {
  orgId  String @map("org_id") @db.Uuid
  userId String @map("user_id") @db.Uuid
  role   String @default("member")
  org    Org    @relation(fields: [orgId], references: [id], onDelete: Cascade)
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@id([orgId, userId])
  @@map("memberships")
}

Prisma 7's client has no Rust engine and needs a driver adapter (@prisma/adapter-pg); the connection URL moves to prisma.config.ts. Whatever the tool, the database still enforces constraints: declare them in the schema, and read the SQL it emits for N+1 queries.

ER diagrams

Crow's foot marks sit at the far end of a line and read "from here, how many over there". The inner mark is the minimum, the outer mark the maximum.

 ──||──  exactly one        ──o|──  zero or one
 ──|<──  one or many        ──o<──  zero or many
 
┌─────────────┐          ┌───────────────┐          ┌─────────────┐
│ users       │          │ memberships   │          │ orgs        │
├─────────────┤          ├───────────────┤          ├─────────────┤
│ id       PK │──||───o<─│ user_id PK,FK │─>o───||──│ id       PK │
│ email    UQ │          │ org_id  PK,FK │          │ slug     UQ │
│ name        │          │ role          │          │ name        │
└─────────────┘          └───────────────┘          └─────────────┘
 
A user has zero or many memberships; each membership has exactly one user.
An org has zero or many memberships; each membership has exactly one org.
Mermaid erDiagramMeaning
||--||exactly one to exactly one
||--o{exactly one to zero or many
||--|{exactly one to one or many
|o--o{zero or one to zero or many
-- vs ..identifying vs non-identifying relationship
erDiagram
  USERS ||--o{ MEMBERSHIPS : "belongs via"
  ORGS  ||--o{ MEMBERSHIPS : has
  ORGS  ||--o{ PROJECTS : owns

Mermaid renders in GitHub and GitLab Markdown. dbdiagram.io (DBML), DataGrip and DBeaver draw diagrams from a live database.

Anti-patterns

Anti-patternSymptomInstead
EAV (entity_id, attribute, value text)every read pivots; no types, constraints or FKsreal columns; jsonb for truly open-ended attributes
comma-separated idsLIKE '%,42,%', no FK, no indexjunction table
god table80 mostly-NULL columns; a type column changes what the others meanone table per entity, 1:1 extension tables
polymorphic FKitem_type + item_id, orphansexclusive arcs or a supertype
repeated columnsphone1, phone2, phone3child table
floats for money0.1 + 0.2 cents offnumeric or integer cents
timestamp for instantsoff by hours after DST or a server movetimestamptz
mutable natural key as PKcascading key updatessurrogate PK + UNIQUE
no FKs "for speed"orphans, cleanup scriptsFKs with indexed columns
magic values (-1, 'N/A', 1970-01-01)special cases in every queryNULL or an explicit status
stored derived data with no source of truthdriftgenerated columns, views, recomputable counters
hand-made sales_2025, sales_2026 tablesunion queries, missed yearsdeclarative partitioning
schema or DB per tenant by defaultslow migrations, connection sprawlorg_id + RLS until a tenant needs more

Recipes

Users, orgs and memberships

The core of most B2B SaaS schemas: people belong to many organizations with a role in each.

CREATE TABLE users (
  id         uuid PRIMARY KEY DEFAULT uuidv7(),
  email      text NOT NULL,
  name       text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX users_email_key ON users (lower(email));
 
CREATE TABLE orgs (
  id         uuid PRIMARY KEY DEFAULT uuidv7(),
  slug       text NOT NULL UNIQUE
             CHECK (slug ~ '^[a-z0-9-]{2,40}$'),
  name       text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
 
CREATE TABLE memberships (
  org_id  uuid NOT NULL REFERENCES orgs ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
  role    text NOT NULL DEFAULT 'member'
          CHECK (role IN ('owner', 'admin', 'member')),
  PRIMARY KEY (org_id, user_id)
);
CREATE INDEX memberships_user_idx ON memberships (user_id);

Tags (many-to-many)

Label rows freely and query "has any" or "has all" of a set of tags.

CREATE TABLE tags (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name text NOT NULL
);
CREATE UNIQUE INDEX tags_name_key ON tags (lower(name));
 
CREATE TABLE post_tags (
  post_id bigint NOT NULL REFERENCES posts ON DELETE CASCADE,
  tag_id  bigint NOT NULL REFERENCES tags ON DELETE CASCADE,
  PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX post_tags_tag_idx ON post_tags (tag_id);
 
-- posts carrying ALL of the given tags
SELECT pt.post_id
FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE lower(t.name) = ANY ($1::text[])
GROUP BY pt.post_id
HAVING count(*) = cardinality($1::text[]);

For "any of", drop the HAVING and select DISTINCT pt.post_id.

Audit log table and trigger

Record who changed which row and how, for every table you attach the trigger to.

CREATE TABLE audit_log (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tbl      text NOT NULL,
  row_id   text,
  op       text NOT NULL,   -- INSERT, UPDATE, DELETE
  actor    text DEFAULT current_setting('app.user_id', true),
  at       timestamptz NOT NULL DEFAULT now(),
  old_row  jsonb,
  new_row  jsonb
);
 
CREATE OR REPLACE FUNCTION audit() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO audit_log (tbl, row_id, op, old_row, new_row)
  VALUES (TG_TABLE_NAME,
    coalesce(to_jsonb(NEW), to_jsonb(OLD)) ->> 'id',
    TG_OP, to_jsonb(OLD), to_jsonb(NEW));
  RETURN NULL;             -- AFTER trigger: ignored
END
$$;
 
CREATE TRIGGER orgs_audit
AFTER INSERT OR UPDATE OR DELETE ON orgs
FOR EACH ROW EXECUTE FUNCTION audit();

Set app.user_id per transaction with set_config('app.user_id', $1, true). Partition or prune the log by at once it grows.

Comment threads with a closure table

Load a whole reply tree, or one branch, with a single indexed join.

CREATE TABLE comments (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  parent_id bigint REFERENCES comments ON DELETE CASCADE,
  body      text NOT NULL
);
CREATE TABLE comment_paths (
  ancestor   bigint REFERENCES comments ON DELETE CASCADE,
  descendant bigint REFERENCES comments ON DELETE CASCADE,
  depth      int NOT NULL CHECK (depth >= 0),
  PRIMARY KEY (ancestor, descendant)
);
CREATE INDEX comment_paths_desc_idx
  ON comment_paths (descendant);
 
-- after inserting comment $1 under parent $2 (or NULL)
INSERT INTO comment_paths (ancestor, descendant, depth)
SELECT $1::bigint, $1::bigint, 0
UNION ALL
SELECT ancestor, $1::bigint, depth + 1
FROM comment_paths WHERE descendant = $2::bigint;
 
-- the subtree under comment $1, shallowest first
SELECT c.*, p.depth FROM comment_paths p
JOIN comments c ON c.id = p.descendant
WHERE p.ancestor = $1 ORDER BY p.depth, c.id;

Multi-tenant RLS policy

Make the database refuse cross-tenant reads and writes even when a query forgets org_id.

CREATE FUNCTION app_org_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
  SELECT nullif(current_setting('app.org_id', true), '')
         ::uuid
$$;
 
DO $$
DECLARE t text;
BEGIN
  FOREACH t IN ARRAY ARRAY['projects', 'tasks'] LOOP
    EXECUTE format(
      'ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
    EXECUTE format(
      'ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
    EXECUTE format(
      'CREATE POLICY tenant_isolation ON %I
         USING (org_id = (SELECT app_org_id()))
         WITH CHECK (org_id = (SELECT app_org_id()))', t);
  END LOOP;
END
$$;
-- per request, in a transaction (Bun.sql):
--   await tx`SELECT set_config('app.org_id', ${id}, true)`

With no setting, app_org_id() is NULL and every row is hidden. Transaction-local settings stay correct behind PgBouncer's transaction pooling.

Drizzle schema definition

The users/orgs/memberships schema in TypeScript, with inferred row types for the app.

db/schema.ts
import { sql } from "drizzle-orm";
import * as p from "drizzle-orm/pg-core";
const id = () =>
  p.uuid().primaryKey().default(sql`uuidv7()`);
const createdAt = () =>
  p.timestamp({ withTimezone: true }).notNull().defaultNow();
 
export const users = p.pgTable("users", {
  id: id(), email: p.text().notNull().unique(),
  name: p.text().notNull(), createdAt: createdAt(),
});
export const orgs = p.pgTable("orgs", {
  id: id(), slug: p.text().notNull().unique(),
  name: p.text().notNull(), createdAt: createdAt(),
});
export const memberships = p.pgTable("memberships", {
  orgId: p.uuid().notNull()
    .references(() => orgs.id, { onDelete: "cascade" }),
  userId: p.uuid().notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  role: p.text({ enum: ["owner", "admin", "member"] })
    .notNull().default("member"),
}, (t) => [p.primaryKey({ columns: [t.orgId, t.userId] }),
  p.index("memberships_user_idx").on(t.userId)]);
export type User = typeof users.$inferSelect;

text({ enum }) narrows the TS type only; add a p.check() or a pgEnum to enforce it in the database. $inferInsert gives the insert type.

References