Language · PostgreSQL 18.4 · sqlfluff 4.2.2 · pgTAP 1.3.4
SQL
Set-based thinking, real indexes, parameterized — production SQL.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou write production SQL for PostgreSQL: correct types and constraints first, set-based queries that do the work in one statement, and indexes proven by EXPLAIN. "Good" here means the query is parameterized, the planner picks the index you intended, writes stay safe under lock, and the migration is reversible.
Stack
- PostgreSQL 18.4 (18.x is current stable; 17.x still in support). Target 18 features unless the deployment is pinned older — confirm the server version with
SHOW server_version;before using them. - psql 18 as the reference client.
\d+,\di+,\timing on,\gexecfor scripted DDL. - Diagnostics:
EXPLAIN (ANALYZE, BUFFERS, SETTINGS),pg_stat_statements,auto_explain(log plans aboveauto_explain.log_min_duration),pg_stat_user_indexes/pg_stat_user_tablesfor index and vacuum health. - Lint/format: sqlfluff 4.2.2 with
dialect = postgres. Commit a.sqlfluff; CI runssqlfluff lintandsqlfluff format. - Testing: pgTAP 1.3.4 (
CREATE EXTENSION pgtap;), run viapg_prove. - Migrations: a versioned tool — Sqitch, Atlas, or Flyway. Every change is a reviewed file, never an ad-hoc
psqlsession against prod. - Use the modern idioms:
GENERATED ALWAYS AS IDENTITY(notserial),uuidv7()for time-ordered UUID keys (18),timestamptz,jsonb,MERGE ... RETURNING,RETURNING old.* / new.*(18), temporalPRIMARY KEY/UNIQUE ... WITHOUT OVERLAPSwithFOREIGN KEY (...) PERIOD (...)(18 — temporal FKs support onlyNO ACTION),ON CONFLICTupserts,SCRAM-SHA-256auth.
Project conventions
- Layout:
migrations/(ordered, one change per file),tests/(pgTAP*_test.sql),queries/or app-embedded parameterized SQL,.sqlfluffat repo root. - Naming:
snake_caseeverywhere. Tables plural (orders), columns singular. PKid; FK<referenced_singular>_id(customer_id). Indexix_<table>_<cols>, uniqueuq_<table>_<cols>, checkck_<table>_<rule>, FKfk_<table>_<ref>. Name every constraint explicitly — never rely on the auto-generatedorders_customer_id_fkeynames in migrations. - Formatting (sqlfluff-enforced): keywords lowercase, one column per line, trailing commas, explicit
ASfor aliases, columns qualified by table alias in any multi-table query. No tabs. - Imports/embedding: SQL lives in
.sqlfiles or parameterized query builders, not concatenated strings. If the ORM emits SQL, log and read it — you own the generated query.
Set-based thinking
Express the whole operation as one statement. No row-by-row loops in PL/pgSQL, no fetching keys into the app to issue per-row follow-up queries (N+1). Replace a loop with
INSERT ... SELECT,UPDATE ... FROM,DELETE ... USING, orMERGE.JOIN, don't correlate. A correlated subquery in
SELECT/WHEREre-runs per outer row; rewrite as a JOIN orLATERAL:-- slow: correlated per-row select o.id, (select count(*) from items i where i.order_id = o.id) as n from orders o; -- set-based select o.id, count(i.*) as n from orders o left join items i on i.order_id = o.id group by o.id;Window functions for ranking, running totals, dedup, gaps — not self-joins.
row_number() over (partition by customer_id order by created_at desc)to pick the latest per group;DISTINCT ON (customer_id) ... ORDER BY customer_id, created_at DESCwhen you only need one row per group.CTEs: in PG12+ a
WITHclause is inlined by default unless referenced more than once, recursive, or marked. AddMATERIALIZEDto force a fence (compute once, reuse); addNOT MATERIALIZEDto force inlining so the planner can push predicates down. Don't assume a CTE is an optimization barrier — it usually isn't anymore.Aggregate with
FILTER (WHERE ...)instead ofsum(case when ...). Usegenerate_seriesfor date spines,LATERALfor top-N-per-group and function joins.Upsert with
INSERT ... ON CONFLICT (key) DO UPDATE SET col = excluded.col; for multi-source merges useMERGE.Collapse child rows in the query, not the app. To return a parent with its children in one round trip, aggregate with
jsonb_agg/array_aggin aLATERALor grouped subquery rather than fetching parents then looping to fetch children (the classic API N+1). Fetch a batch of parents and their children with a singleWHERE parent_id = ANY($1), never one query per parent.
Schema
- Types:
text(notvarchar(n)unless a real limit exists — enforce length with aCHECK).timestamptznevertimestamp— store UTC, let the client localize.numeric(19,4)for money, neverfloat/real.bigint(oruuidv7) for PKs.jsonbnotjson.booleannotchar(1)/intflags. Enum via a lookup table (FK) when values change, nativeENUMonly for a truly fixed set. - Keys:
id bigint generated always as identity primary key, orid uuid primary key default uuidv7()when you need client-generatable, index-friendly keys. Avoid randomuuidv4()PKs — they fragment B-tree inserts. - Constraints are the spec:
NOT NULLon every column that logically must have a value; sensibleDEFAULTs. DeclareFOREIGN KEYwith an explicitON DELETE(RESTRICT/CASCADE/SET NULL) — pick deliberately.CHECKfor domain rules (ck_orders_total_nonneg CHECK (total >= 0)).UNIQUEfor natural keys. - Normalize to 3NF by default; denormalize only with a measured read reason and a plan to keep copies consistent (trigger or generated column). Use generated columns (
GENERATED ALWAYS AS (...) STORED, or virtual/default in 18) instead of app-maintained derived fields. - Partition tables that grow unbounded and are queried by a range/list key (time-series by month via
PARTITION BY RANGE (created_at)); index the partition key.
Indexing
- Index the columns in
WHERE,JOIN ... ON, andORDER BY. Every FK column needs an index (Postgres does not create one automatically) or deletes/updates on the parent do seq scans. - Composite column order: equality-filtered columns first, then the range column, then the
ORDER BYcolumn —(status, created_at)servesWHERE status = $1 ORDER BY created_at. The wrong order makes the index unusable for that query. Skip-scan (18) helps when a leading column is absent but don't rely on it as the design. - Covering:
CREATE INDEX ... (a, b) INCLUDE (c)to get index-only scans (needsVACUUM-fresh visibility map). Partial:CREATE INDEX ix_orders_open ON orders (created_at) WHERE status = 'open'for a hot subset — smaller, faster, cheaper to maintain. - Expression index when you filter on a function:
WHERE lower(email) = $1needsCREATE INDEX ON users (lower(email)). GIN forjsonbcontainment (@>), full-text (tsvector), and arrays; BRIN for huge append-only tables correlated with physical order; GiST for ranges/geo. - Anchored
LIKE 'foo%'uses a plain B-tree only in theCcollation; under any other collation add atext_pattern_ops(orvarchar_pattern_ops) operator class:CREATE INDEX ON users (email text_pattern_ops). Anything with a leading%still needs trigram GIN. - Index gets skipped — and you must recognize it in
EXPLAIN— when: a function/expression wraps the column (WHERE date(ts) = ...), a leading wildcard (LIKE '%foo') — use trigram GIN or reverse the pattern; an implicit cast mismatches the type (WHERE bigint_col = '123'text literal, or comparingtexttocitext); low selectivity (planner prefers a seq scan and is often right);ORacross columns (rewrite asUNIONor rely on a bitmap-or). Fix implicit casts by matching literal/param types to the column type. - Don't over-index. Every index is write amplification and bloat. Drop indexes with
idx_scan = 0inpg_stat_user_indexes. Prefer one composite index over three single-column ones the query can't combine. Keep updates HOT (leavefillfactorheadroom, avoid indexing hot-updated columns).
Query safety
- Always parameterized. Pass values as
$1, $2/ bound parameters. Never interpolate user input into SQL text — that is the SQLi vector. In dynamic DDL useformat('... %I ... %L', ident, literal)withquote_ident/quote_literal, never||. - Explicit column lists in
SELECTandINSERT— neverSELECT *(breaks on schema change, ships unused bytes, blocks index-only scans, couples callers to column order). - Qualify every column with its table alias in multi-table queries. Add
ASon derived columns. - Bound every unfiltered read with
LIMIT. Paginate by keyset (WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 50), notOFFSET— offset scans and discards. - NULL semantics:
=/<>against NULL yields NULL. UseIS [NOT] DISTINCT FROMfor null-safe equality,COALESCEfor defaults, and rememberNOT IN (subquery)is a trap when the subquery can return NULL — useNOT EXISTS. - Use
RETURNINGto read generated keys, defaults, and computed columns back fromINSERT/UPDATE/DELETE/MERGEin the same statement — never follow a write with aSELECTround-trip to fetch the id you just wrote.
Transactions
- Default isolation is READ COMMITTED. Use REPEATABLE READ for multi-statement reads that must be consistent, SERIALIZABLE for invariants across rows (and be ready to retry on
40001serialization failures). - Keep transactions short. Do no network calls, no user waits, no large computations inside a transaction. Set
statement_timeoutandidle_in_transaction_session_timeoutso a stuck txn can't hold locks and block vacuum. - Avoid deadlocks by acquiring locks in a consistent order across all code paths. For work queues,
SELECT ... FOR UPDATE SKIP LOCKED LIMIT ninstead of application-level locking. UseSELECT ... FOR NO KEY UPDATEwhen you don't touch the key. Use advisory locks (pg_advisory_xact_lock) for cross-row coordination. - Read-modify-write must lock the row (
FOR UPDATE) or use an atomicUPDATE ... SET n = n + 1/ optimistic version column — never read then write unguarded.
Reading EXPLAIN
- Run
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)and read from the innermost node out.ANALYZEexecutes the query — never run it on a mutating statement outside a transaction you roll back. - Estimated vs actual rows far apart → stale stats or correlated predicates the planner can't model.
ANALYZE <table>, raise the column's statistics target, or add extended statistics (CREATE STATISTICS). - Red flags:
Seq Scanon a large table with a selective filter (missing/unused index);Nested Loopwith a largeloops=count (missing hash/merge join or index on inner side);Sort/HashreportingdiskorBatches > 1(raisework_mem); highRows Removed by Filter(index isn't selective enough);Buffers: shared read=large (cold cache / too much I/O). Confirm index-only scans showHeap Fetches: 0.
Performance & maintenance
- Autovacuum is not optional. Every
UPDATE/DELETEleaves dead tuples that bloat the heap and its indexes until vacuumed. For high-churn tables lowerautovacuum_vacuum_scale_factor(per-table, e.g.0.02) so vacuum triggers on real churn instead of a percentage of a huge table; watchn_dead_tup,last_autovacuum, andn_ins_since_vacuuminpg_stat_user_tables. ANALYZEafter any bulk load or backfill.COPYandINSERT ... SELECTdon't refresh planner stats immediately; a large load followed by a query can pick a terrible plan on stale row counts. RunANALYZE <table>(orVACUUM ANALYZE) explicitly before benchmarking.- Keep the visibility map fresh (
VACUUM) so index-only scans skip heap fetches (Heap Fetches: 0). Leavefillfactorheadroom (e.g. 90) on hot-updated tables soUPDATEs stay HOT and don't touch every index. - Find the slow queries with data, not intuition: rank
pg_stat_statementsbytotal_exec_timeandmean_exec_time, and checkshared_blks_readfor I/O-bound ones.pg_stat_statements_reset()between load tests. - Pool connections (PgBouncer transaction mode, or a server-side pool) — each Postgres backend is a process, so thousands of idle app connections starve
work_memand CPU. In transaction pooling, session state (SET, server-side prepared statements, advisory session locks) doesn't survive across transactions — scope it accordingly. - Rebuild bloated indexes online with
REINDEX INDEX CONCURRENTLY(no long lock); a heavily-updated index accumulates dead entries and grows even afterVACUUM. Detect it by comparing index size to live tuples, or with a bloat-estimate query, before rebuilding.
Migrations
- Every migration is reversible (explicit down / verify), reviewed, and idempotent-safe where possible (
IF NOT EXISTSon additive DDL). CREATE INDEX CONCURRENTLYon any populated table — it doesn't take a blocking lock. It cannot run inside a transaction, so keep it in its own migration step; if it fails it leaves anINVALIDindex you must drop and recreate.- Safe patterns on big tables:
- Adding a column with a static
DEFAULTis metadata-only (no rewrite) in modern Postgres — fine. A volatile default still rewrites. - Add
NOT NULLwithout a full-tableACCESS EXCLUSIVEscan by first adding aCHECK (col IS NOT NULL) NOT VALID, thenVALIDATE CONSTRAINT(takes a weaker lock), then attachNOT NULL(18 can use the validated constraint to skip the scan). - Add FKs as
NOT VALID, thenVALIDATE CONSTRAINTin a separate step. - Backfill in batches (
UPDATE ... WHERE id BETWEEN ... LIMITloops), not one giantUPDATEthat locks the table and bloats.
- Adding a column with a static
- Set a short
lock_timeoutbefore DDL so a migration waits behind a long query instead of queuing and blocking everything behind it. Expand-and-contract (add new, dual-write, migrate, drop old) for column/type changes — never a blockingALTER TYPErewrite on a live hot table.
Testing
- pgTAP for schema and logic tests:
has_table,col_not_null,col_type_is,has_index,fk_ok,col_has_check,results_eq/results_nefor query output,throws_okfor constraint enforcement,function_returns. Wrap each test inBEGIN; ... ROLLBACK;(pgTAP'sruntestsdoes this) so tests leave no residue. - Test the constraints, not just the happy path: assert that inserting a bad row fails with the expected
SQLSTATE. Test triggers and generated columns produce the right values. Test that a query returns the exact expected rows on a seeded fixture. - Add a plan-shape guard for critical queries: run
EXPLAINin a test and assert anIndex Scan(notSeq Scan) appears, so a regression that drops the index is caught. - Run tests against the same major Postgres version as production, on a schema built by the migrations (not a hand-crafted one).
Security
- Least privilege: the app connects as a role that is not superuser and not the table owner. Grant only
SELECT/INSERT/UPDATE/DELETEon the specific tables; noCREATE/DDL at runtime. Use column-levelGRANTto hide sensitive columns. - Parameterize — restated because it is the number-one issue. Any dynamic identifier must go through
format('%I')/quote_ident. - Row-Level Security for multi-tenant data:
ALTER TABLE ... ENABLE ROW LEVEL SECURITYplus aPOLICYkeyed oncurrent_setting('app.tenant_id'). Don't rely solely on app-sideWHERE tenant_id = .... SECURITY DEFINERfunctions must pinSET search_path = pg_catalog, pg_tempto prevent search-path hijacking; keep their bodies minimal.- Authentication: SCRAM-SHA-256 (md5 is deprecated in 18 and being removed). Require TLS (
sslmode=verify-full) for connections crossing a network. Usepgcryptofor at-rest secrets; never store plaintext passwords or log parameter values containing PII.
Do
- Do the work in a single set-based statement; JOIN over correlated subqueries.
- Declare types,
NOT NULL, defaults, PK/FK/CHECK/UNIQUE — make the schema reject bad data. - Index
WHERE/JOIN/ORDER BYcolumns; order composite keys equality-then-range-then-sort; index every FK. - Parameterize every value; list columns explicitly; qualify columns by alias.
- Read
EXPLAIN (ANALYZE, BUFFERS)before claiming a query is fast; compare estimated vs actual rows. CREATE INDEX CONCURRENTLY, validate constraintsNOT VALID→VALIDATE, backfill in batches.- Keep transactions short; set
statement_timeoutandidle_in_transaction_session_timeout. - Use
timestamptz,numericfor money, identity/uuidv7()keys,jsonb.
Avoid
SELECT *in application queries → explicit column list.- String-concatenated SQL / interpolated user input → bound parameters (
$1) andformat('%I'/%L')for dynamic DDL. - Row-by-row loops and app-side N+1 →
INSERT/UPDATE ... FROM SELECT,MERGE, JOINs,LATERAL. serial/bigserial→GENERATED ALWAYS AS IDENTITY.uuidv4()PKs →uuidv7().timestamp/varchar(n)cushions /floatmoney /json→timestamptz/text+CHECK/numeric/jsonb.- Functions wrapping indexed columns (
lower(col),date(ts)) with no matching expression index; implicit casts (bigint_col = '5') that disable the index. OFFSETpagination on large tables → keyset pagination.NOT IN (subquery)with nullable columns →NOT EXISTS.CREATE INDEX(blocking) or a full-table-rewritingALTERon a live big table → concurrent/NOT VALID/expand-contract.- Ignoring
EXPLAIN, trusting the ORM's emitted SQL blind, or shipping an index without checking it's actually used.
When you code
- Make small, single-purpose migrations. One logical schema change per file with its down/verify.
- Before proposing a query as final: run
sqlfluff lint, run it underEXPLAIN (ANALYZE, BUFFERS)on realistic data, and confirm the intended index is chosen. Run the pgTAP suite. - State the assumed Postgres major version and the target table's approximate row count and write rate — they change the right index and migration strategy. If unknown, ask.
- Flag any migration that takes an
ACCESS EXCLUSIVElock, rewrites a table, or backfills millions of rows, and propose the online-safe alternative before running it. - Never run destructive DDL (
DROP,TRUNCATE, type-changingALTER) orEXPLAIN ANALYZEon a mutating statement against production data without explicit confirmation and a backup/rollback path.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on PostgreSQL 18.4 · sqlfluff 4.2.2 · pgTAP 1.3.4.