---
name: postgresdb
description: Use when designing or reviewing a PostgreSQL schema (types, constraints, normalization), writing or optimizing SQL, choosing or adding indexes, reading EXPLAIN/ANALYZE, planning a migration (expand-contract, concurrent index, batched backfill), or operating/securing Postgres (roles, RLS, PgBouncer pooling, VACUUM/autovacuum, pg_stat_statements, declarative partitioning, backups/PITR). Triggers - "slow query", "add an index", "EXPLAIN", "design this table", "zero-downtime migration", "row level security", "connection pool", "N+1", "JSONB", "full-text search", "pgvector". PostgreSQL 16; ORM-agnostic (SQLAlchemy/Alembic, Prisma, golang-migrate, raw SQL).
tags: [postgres, sql, database, migrations]
recommends: [secure-coding]
origin: risco
---

# PostgreSQL — schema, indexing, queries, ops

Engine-level PostgreSQL 16 guidance: design correct schemas, pick the right index, read EXPLAIN and
fix slow SQL, run zero-downtime migrations, and operate/secure the database. Tooling-agnostic; every
example is runnable.

## When to use / When NOT to use

> **⚠️ SDD new-feature gate — read this first.** If this skill fired on a **new, non-trivial feature or behaviour change** and there is **no approved spec + plan** under `02-DOCS/wiki/sdd/`, STOP — do **not** write feature code yet. Hand off to `../specify/SKILL.md` first: it runs brainstorm → spec → plan → tasks before any code, then routes back here once the plan is approved. Build here directly only for a genuinely one-line / low-risk change. Method: `../sdd/SKILL.md`.

**When to use:**

- Schema/DDL decisions: types, keys, constraints, generated/identity columns, enum-vs-lookup, jsonb-vs-column.
- Any query that is slow, scans too much, or returns wrong cardinality; reading a plan.
- Index decisions: which kind, column order, partial/covering, and when NOT to add one.
- Migrations against tables with real data or live traffic.
- Concurrency: isolation, locking, deadlocks, queues.
- Ops: pooling, vacuum, monitoring, partitioning, backups, RLS, least-privilege roles.

**When NOT to use:**

- ORM-API ergonomics (Prisma `updateMany` count trap, SQLAlchemy session lifecycle) → your ORM's own docs. This skill owns the **SQL the ORM emits and the engine behavior underneath**.
- Non-Postgres engines (MySQL/SQLite/ClickHouse) — different MVCC, locking, planner.
- App-layer caching / Redis / Kafka as products; only Postgres-as-queue via `SKIP LOCKED` is in scope.
- Cloud-vendor console clicks — we give the SQL/params, not the RDS/Cloud SQL UI path.

Deep dives: [schema-and-indexing](references/schema-and-indexing.md) (types, constraints, every index
kind, bloat) · [query-optimization](references/query-optimization.md) (EXPLAIN, joins, concurrency,
JSONB/FTS/pgvector) · [migrations](references/migrations.md) (zero-downtime DDL, per-ORM) ·
[operations-and-security](references/operations-and-security.md) (roles, RLS, pooling, vacuum,
partitioning, backups).

## Non-negotiables

1. `timestamptz` always for events; store UTC. Never naive `timestamp`.
2. Money is `numeric(19,4)`, never `float`/`double precision`.
3. Every FK gets a covering index — Postgres does **not** auto-index FK columns.
4. `text` + `CHECK (length(...) <= n)`, not `varchar(n)` as a length hack.
5. Index creation on a live table is **always** `CONCURRENTLY` (so it cannot run inside a txn).
6. Never `ADD COLUMN ... NOT NULL` without a default/backfill plan; never add a volatile default on a large table without a batched backfill.
7. Read the plan before adding an index: `EXPLAIN (ANALYZE, BUFFERS)` or it didn't happen.
8. Migrations are forward-only in prod; never edit an applied migration.
9. `SET lock_timeout` + `SET statement_timeout` around DDL on hot tables.
10. RLS is opt-in per table and the **table owner bypasses it** — verify with a non-owner role (use `FORCE ROW LEVEL SECURITY` for the owner too).

## Decision rules

Fast lookups; runnable DDL lives in the references.

### Pick the column type

| Use case | Correct type | Avoid | Why |
| --- | --- | --- | --- |
| Surrogate PK (internal) | `bigint GENERATED ALWAYS AS IDENTITY` | `serial`, `int` | identity is SQL-standard, no sequence-ownership gotchas; `bigint` avoids 2.1B overflow |
| Surrogate PK (public/distributed) | `uuid` v7 | `uuid` v4 | v7 is time-ordered → less B-tree fragmentation than random v4 |
| Natural text id (slug, sku) | `text` + `UNIQUE` + `CHECK` | `varchar(n)` | length via CHECK; no rewrite to widen later |
| Money / exact decimal | `numeric(19,4)` | `float8`, `money` | binary floats drift; `money` has locale issues |
| Timestamp (event) | `timestamptz` | `timestamp` | stores a UTC instant; naive timestamp loses zone |
| Duration | `interval` | int seconds | self-documenting, arithmetic-safe |
| Small closed set, stable | `enum` | `text` w/o CHECK | type safety; but see lookup-table note |
| Evolving set, joinable | lookup table + FK | `enum` | `ALTER TYPE ... ADD VALUE` is awkward; FK gives joins + soft-retire |
| Flag | `boolean` | `int`, `varchar` | three-valued NULL still possible — add `NOT NULL DEFAULT` |
| Tags (read-mostly) | `text[]` + GIN | comma string | array ops + GIN containment |
| Tags (relational) | join table | `text[]` | when you need FK integrity / per-tag rows |
| Semi-structured | `jsonb` | `json`, `text` | binary, indexable, dedup keys; promote hot keys to columns |
| IP / CIDR | `inet` / `cidr` | `text` | validation + operators |
| Time range (booking) | `tstzrange` + GiST | two columns | `&&` overlap + exclusion constraint |

### Pick the index

| Access pattern | Index | DDL | Notes |
| --- | --- | --- | --- |
| `=` / `<` `>` / range / `ORDER BY` | btree (default) | `CREATE INDEX ix_orders_status ON orders (status)` | also enforces uniqueness |
| `LIKE 'prefix%'` | btree + `text_pattern_ops` | `CREATE INDEX ix_users_email_pat ON users (email text_pattern_ops)` | only for C-locale/prefix; not `%suffix` |
| Case-insensitive eq | expr index or `citext` | `CREATE INDEX ix_users_lemail ON users (lower(email))` | query must use `lower(email)` too |
| `@>` jsonb / array containment | GIN | `CREATE INDEX ix_orders_meta ON orders USING gin (meta)` | `jsonb_path_ops` if only `@>` |
| Full-text `@@` | GIN on tsvector | `CREATE INDEX ix_orders_search ON orders USING gin (search)` | index a generated `tsvector` column |
| Range overlap / exclusion / geo | GiST | `CREATE INDEX ix_book_during ON bookings USING gist (during)` | also PostGIS geometry |
| Huge append-only time-series | BRIN | `CREATE INDEX ix_events_ts ON events USING brin (created_at)` | needs physical correlation |
| Vector similarity | hnsw (pgvector) | `CREATE INDEX ix_docs_embed ON docs USING hnsw (embedding vector_cosine_ops)` | see query-optimization |
| Dedup only | unique btree | `CREATE UNIQUE INDEX uq_users_email ON users (email)` | constraint = index |

Hash indexes: almost never — equality-only, no multicolumn, rarely beats btree even though WAL-logged
since PG10.

### When NOT to add an index

- Low-selectivity boolean / `status` with few distinct values (planner ignores it; seq scan wins).
- Tiny tables (a seq scan reads one page; the index adds maintenance for nothing).
- Write-heavy columns rarely filtered — every index is a write tax.
- A column already the **left prefix** of an existing composite index.
- Redundant with a `UNIQUE` constraint — the constraint already created an index.

## Copy-paste patterns

### Canonical table (types + constraints + identity)

```sql
-- GOOD: identity PK, public uuid, FK with action, numeric money, timestamptz,
-- status via lookup FK, generated tsvector, CHECK constraints.
CREATE TABLE orders (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id    uuid NOT NULL DEFAULT gen_random_uuid(),  -- v4; see schema ref for v7
    user_id      bigint NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
    status       text NOT NULL REFERENCES order_statuses (code) ON UPDATE CASCADE,
    amount       numeric(19,4) NOT NULL CHECK (amount >= 0),
    currency     text NOT NULL CHECK (length(currency) = 3),
    note         text,
    search       tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(note, ''))) STORED,
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now(),
    UNIQUE (public_id)
);
CREATE INDEX ix_orders_user_id ON orders (user_id);     -- FKs are NOT auto-indexed
CREATE INDEX ix_orders_status  ON orders (status);
```

```sql
-- BAD: every column is a future migration or a bug.
CREATE TABLE orders (
    id          serial PRIMARY KEY,            -- sequence-ownership gotchas; use IDENTITY
    user_id     int REFERENCES users(id),      -- int overflows at 2.1B; no FK index
    status      varchar(20),                   -- length hack; no constraint on values
    amount      float,                         -- money drift
    created_at  timestamp DEFAULT now()        -- naive: loses the zone
);
```

`updated_at` is not auto-maintained — add a `BEFORE UPDATE` trigger (see schema ref) or set it in the
app; Postgres has no `ON UPDATE` clause.

### Composite index column order

Equality columns first, then the range/sort column.

```sql
-- Query: WHERE user_id = $1 AND created_at >= $2 ORDER BY created_at DESC
-- GOOD: equality (user_id) then range (created_at)
CREATE INDEX ix_orders_user_created ON orders (user_id, created_at DESC);

-- BAD: range-first index cannot satisfy the equality efficiently for this query
CREATE INDEX ix_orders_created_user ON orders (created_at, user_id);
```

Confirm with `EXPLAIN` that the plan shows `Index Cond: (user_id = ... AND created_at >= ...)`, not a
`Filter:`.

### Partial + covering (INCLUDE) index

```sql
-- Partial: index only the rows you query (smaller, hotter)
CREATE INDEX ix_orders_active ON orders (user_id, created_at DESC)
WHERE status <> 'cancelled';

-- Covering: INCLUDE non-key columns to enable an index-only scan
CREATE INDEX ix_orders_user_cover ON orders (user_id) INCLUDE (amount, created_at);
```

Index-only scan requires a recently-`VACUUM`ed table; confirm `Heap Fetches: 0` in `EXPLAIN (ANALYZE)`.

### Keyset (cursor) pagination — not OFFSET

```sql
-- GOOD: keyset on a stable composite sort; uses ix_orders_user_created
SELECT id, amount, created_at
FROM orders
WHERE user_id = $1
  AND (created_at, id) < ($2, $3)   -- row-value comparator = last row of prev page
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- BAD: OFFSET scans and discards 100000 rows every page (O(n))
SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
```

The index must match the `ORDER BY` direction exactly; include the tiebreaker (`id`).

### UPSERT done right

```sql
-- GOOD: insert-or-update; EXCLUDED is the row that failed to insert
INSERT INTO inventory (sku, qty)
VALUES ($1, $2)
ON CONFLICT (sku)
DO UPDATE SET qty = inventory.qty + EXCLUDED.qty
WHERE inventory.qty + EXCLUDED.qty >= 0   -- guard
RETURNING id, qty;

-- DO NOTHING returns no row on conflict; wrap to always get the row:
WITH ins AS (
    INSERT INTO tags (name) VALUES ($1)
    ON CONFLICT (name) DO NOTHING
    RETURNING id
)
SELECT id FROM ins
UNION ALL
SELECT id FROM tags WHERE name = $1 LIMIT 1;
```

### Queue with SKIP LOCKED

```sql
-- GOOD: contention-free job claim; concurrent workers never block each other
UPDATE jobs
SET status = 'processing', locked_at = now()
WHERE id = (
    SELECT id FROM jobs
    WHERE status = 'pending'
    ORDER BY created_at
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
RETURNING id, payload;
```

`SKIP LOCKED` skips rows another txn holds; `FOR UPDATE` alone would serialize all workers.

### Kill the N+1

```sql
-- BAD: application loops, one query per order (N+1)
--   for o in orders: SELECT * FROM order_items WHERE order_id = o.id
-- GOOD: one set-based query
SELECT o.id, json_agg(i.*) AS items
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.user_id = $1
GROUP BY o.id;

-- Top-N-per-group: JOIN LATERAL, not a window-filter scan
SELECT u.id, recent.*
FROM users u
JOIN LATERAL (
    SELECT id, amount, created_at FROM orders
    WHERE user_id = u.id ORDER BY created_at DESC LIMIT 3
) recent ON true;
```

An ORM emitting N queries is the same bug — fix it at the SQL boundary, not with a cache.

### EXPLAIN, the right way

```sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = $1 AND created_at >= $2;
```

Read these four first:

1. **Estimated vs actual rows** — a large gap means stale stats; run `ANALYZE`.
2. **The most expensive node** — highest `actual time` × `loops`.
3. **`Seq Scan` on a big table** where you expected an index.
4. **`Rows Removed by Filter`** — the predicate was not pushed to an index.

Full method in [query-optimization](references/query-optimization.md).

## Anti-patterns / rationalizations → STOP

| Rationalization | Reality → STOP |
| --- | --- |
| "I'll add the FK index later, the query works now" | Unindexed FK = seq scan + heavy lock cascade on parent `DELETE`/`UPDATE`. Index it now. |
| "UUID PK is fine everywhere" | Random v4 fragments the B-tree and bloats WAL. Use `IDENTITY` internally or uuid **v7**. |
| "`SELECT count(*)` to check existence" | Counts the whole match. Use `EXISTS (SELECT 1 ...)`. |
| "CTEs are just for readability" | Pre-12 they were optimization fences; PG12+ inlines unless `MATERIALIZED`. Know which you want. |
| "`NOT IN (subquery)`" | NULL-unsafe (one NULL → empty result) and slow. Use `NOT EXISTS`. |
| "Store money as float, round on display" | Silent drift across arithmetic. `numeric(19,4)`. |
| "`ADD COLUMN ... NOT NULL DEFAULT now()`" | Volatile default rewrites the table under ACCESS EXCLUSIVE. Non-volatile constant is instant (PG11+). |
| "One big `jsonb` blob beats columns" | No constraints, no per-key stats, GIN bloat. Promote hot keys to typed columns. |
| "RLS policy calling `auth.uid()` per row" | Re-evaluated per row. Wrap: `(SELECT auth.uid())` so it runs once. |
| "`CREATE INDEX` in the migration is fine" | Blocks writes for the whole build. `CREATE INDEX CONCURRENTLY` (outside a txn). |
| "`VACUUM FULL` will fix bloat" | Takes ACCESS EXCLUSIVE, rewrites the table. Use autovacuum tuning / `REINDEX CONCURRENTLY`. |

## Quick reference

### Isolation levels

| Level | Prevents | Use when | Note |
| --- | --- | --- | --- |
| Read Committed (default) | dirty reads | most OLTP | each statement sees a fresh snapshot |
| Repeatable Read | + non-repeatable / phantom (snapshot) | multi-statement consistent read | may raise `40001`; retry |
| Serializable (SSI) | + write skew | invariants across rows | retry `40001` with backoff |

Retry the txn on SQLSTATE `40001` (serialization_failure) and `40P01` (deadlock_detected).

### Lock modes (DDL)

`CREATE INDEX CONCURRENTLY` takes SHARE UPDATE EXCLUSIVE (allows writes); plain `CREATE INDEX`,
`ALTER TABLE ... TYPE`, `ADD COLUMN` with a volatile default, and `VACUUM FULL` take ACCESS EXCLUSIVE
(blocks everything). Full table in [migrations](references/migrations.md#lock-impact-reference).

### Diagnostic one-liners

```sql
-- Unindexed foreign keys
SELECT c.conrelid::regclass AS tbl, a.attname AS col
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
WHERE c.contype = 'f'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid AND (i.indkey::int2[])[0] = a.attnum
  );

-- Top slow queries (needs pg_stat_statements)
SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms,
       round(total_exec_time::numeric, 2) AS total_ms, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

-- Dead tuples / bloat candidates
SELECT relname, n_dead_tup, n_live_tup, last_autovacuum
FROM pg_stat_user_tables WHERE n_dead_tup > 1000 ORDER BY n_dead_tup DESC;

-- Blocking locks
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, blocked.query AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks gl ON gl.locktype = bl.locktype AND gl.database IS NOT DISTINCT FROM bl.database
  AND gl.relation IS NOT DISTINCT FROM bl.relation AND gl.granted
JOIN pg_stat_activity blocking ON blocking.pid = gl.pid;

-- Cache hit ratio (aim > 0.99)
SELECT sum(heap_blks_hit) / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS ratio
FROM pg_statio_user_tables;

-- Unused indexes
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY relname;
```

## Verify

Run `scripts/verify.sh` from your project root. It lints discovered SQL with `sqlfluff` (if
configured), syntax-sanity-checks migration files (the quote/paren balance check is dollar-quote and
block-comment aware) and flags foot-guns (`CREATE INDEX` without `CONCURRENTLY` in a migration,
`ADD COLUMN ... NOT NULL` without `DEFAULT`, `VACUUM FULL`), and — only if `DATABASE_URL` and `psql`
are present — checks that `pg_stat_statements` is enabled. It exits non-zero **only** on a real
`sqlfluff` lint error; everything else (missing tools, heuristic quote/paren warnings, DB unreachable)
is advisory `[skip]`/`[warn]`, never a failure. Runs on stock macOS bash 3.2; it never writes and
never connects without `DATABASE_URL`.

## Project grounding (02-DOCS + CLAUDE.md)

When this skill runs in a project with a `02-DOCS/` layer (the
[`harness`](../harness/SKILL.md) Karpathy wiki), record this
project's database decisions there and index them from the root `CLAUDE.md`, so the next
agent inherits the conventions instead of re-deriving them.

1. **Find the article** `02-DOCS/wiki/stack/postgresdb.md`, linked from a `## Knowledge map` section in the root
   `CLAUDE.md`.
2. **If missing or stale**, create/update it with the project's real choices — schema and naming conventions, the migration tool, indexing/partitioning decisions, the pooling setup, and any RLS policies —
   then add/refresh the `CLAUDE.md` link (create the `## Knowledge map` section, and
   `CLAUDE.md` itself, if absent).
3. **Read it first on every use** and stay consistent; when a convention changes, update the
   article (bump its `Updated` date) in the same change.

No `02-DOCS/` layer? Skip silently (optionally suggest `harness`). Unlike the
brand study, technical conventions are *recorded, not gated* — never block the task on this.

## See Also

- [references/schema-and-indexing.md](references/schema-and-indexing.md) — types, constraints, all index kinds, bloat.
- [references/query-optimization.md](references/query-optimization.md) — EXPLAIN, joins, concurrency, JSONB/FTS/pgvector.
- [references/migrations.md](references/migrations.md) — zero-downtime DDL, lock-impact table, per-ORM.
- [references/operations-and-security.md](references/operations-and-security.md) — roles, RLS, pooling, vacuum, partitioning, backups.
- Sibling skills: [`harness`](../harness/SKILL.md) (scaffolds the `01-TOOLS/POSTGRES` operational tool). Stack siblings: [`fastapi`](../fastapi/SKILL.md), [`nextjs`](../nextjs/SKILL.md), [`go`](../go/SKILL.md), [`flutter`](../flutter/SKILL.md), [`secure-coding`](../secure-coding/SKILL.md), [`deployment`](../deployment/SKILL.md).
- Out of scope here — these are external tools with their own upstream documentation, not skills in this collection: ORM-surface traps (Prisma's `updateMany` count, serverless connection exhaustion) and per-runner migration wiring (Prisma, Alembic, golang-migrate, Drizzle, …). This skill owns the engine-level SQL and migration mechanics those tools sit on top of; for the tool's own API/CLI, read that tool's docs.
