postgres (engine specialist)
The Postgres-specific layer. Cross-engine conventions - schema design, migrations, indexing and transaction rules, connection handling - are the cross-engine database hub's; load that hub first where the install has it, and do not restate it here. RLS basics and least-privilege logins are the data-layer security skill's; the .NET/EF Core side is the ORM-side skill's (EF Core / Dapper). This file is only what changes because the engine is Postgres, and stands on its own when the hub is absent.
Schema and types
- Keep every identifier lowercase
snake_case and unquoted. Postgres folds unquoted identifiers to lowercase; a quoted mixed-case name ("firstName") must be quoted forever and breaks ORMs and tools. Inheriting mixed-case? Wrap a snake_case view as a compatibility layer.
ADD CONSTRAINT IF NOT EXISTS does not exist in Postgres - it is a syntax error. Guard idempotent constraint DDL with a pg_constraint check:
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'profiles_owner_unique' AND conrelid = 'public.profiles'::regclass)
THEN ALTER TABLE public.profiles ADD CONSTRAINT profiles_owner_unique UNIQUE (owner_id);
END IF;
END $$;
- Growable value set:
TEXT + CHECK (col IN (...)). A native CREATE TYPE ... AS ENUM only for a truly fixed set - adding a value needs ALTER TYPE, reordering is painful.
- Partition (
PARTITION BY RANGE) once a table passes ~100M rows or is time-series with date-scoped reads: the planner prunes to relevant partitions, and dropping old data is an instant DROP TABLE events_2023_01, not a lock-heavy DELETE + VACUUM.
- Postgres never auto-indexes foreign-key columns. Every FK needs its own index or joins and
ON DELETE CASCADE become full scans - audit with pg_constraint vs pg_index.
Indexing - match the type to the query
| Access pattern |
Index |
=, <, >, between, in, is null, order by |
B-tree (default) |
jsonb containment, arrays, full-text tsvector |
GIN |
| geometric / range types, nearest-neighbor (KNN) |
GiST |
huge naturally-ordered / append-only (e.g. created_at) |
BRIN (10-100x smaller than B-tree) |
| pure equality, marginal win over B-tree |
Hash |
- Composite leftmost-prefix: an index on
(a, b) serves WHERE a and WHERE a AND b, never WHERE b alone. (Equality-first / range-last column ordering is the cross-engine hub's.)
- A partial index is used only when the planner proves the query predicate implies the index
WHERE - keep that predicate identical to the query's own condition, and beware parameterized queries that can't match a literal-based filter.
- JSONB: a B-tree cannot serve
@>. Use GIN; default jsonb_ops covers all operators, jsonb_path_ops covers only @>, @?, @@ (not the key-existence ?/?&/?|) at ~half the size. For scalar-key equality use an expression index, not GIN:
CREATE INDEX products_attrs_gin ON products USING GIN (attributes); -- @>, ?, ?&, ?|
CREATE INDEX products_brand_idx ON products ((attributes->>'brand')); -- attributes->>'brand' = 'Nike'
Queries and the planner
- SARGability is engine-neutral and the cross-engine hub's (its SQL style reference carries the full section): leave the indexed column bare. The Postgres spellings of the trap:
| Non-sargable |
Rewrite |
EXTRACT(YEAR FROM d) = 2026 |
d >= '2026-01-01' AND d < '2027-01-01' |
date_trunc('day', ts) = :d |
ts >= :d AND ts < :d + INTERVAL '1 day' |
id::text = '42' (cast on the column) |
id = 42 |
- Must filter on a function (e.g. case-insensitive email)? The escape hatch is a matching expression index:
CREATE INDEX ON users ((lower(email))) then WHERE lower(email) = :v.
- When only existence matters, use
EXISTS (semi-join) not a join - a join on a non-unique key multiplies rows, and a predicate on the right table's columns in WHERE silently turns a LEFT JOIN into an inner join.
- Rewrite
OR across different columns as UNION ALL branches so each branch can seek. Avoid the catch-all col = :p OR :p IS NULL on hot paths - use a query per shape.
- Batch instead of N+1:
WHERE user_id = ANY($1::bigint[]), one round trip, not N.
- Atomic upsert closes the check-then-insert race:
INSERT INTO settings (user_id, key, value) VALUES (123,'theme','dark')
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();
INSERT INTO page_views (page_id, user_id) VALUES (1,123) ON CONFLICT DO NOTHING;
- Bulk load: multi-row
INSERT ... VALUES (...),(...) (~1000 rows/statement) over per-row; COPY for large imports (fastest path).
Read-path diagnostics
EXPLAIN (ANALYZE, BUFFERS) is the primary tool - it runs the query and shows real timing and IO. Read for:
Seq Scan on a large table -> missing index.
- high
Rows Removed by Filter -> poor selectivity.
Buffers: read >> hit -> not cached (memory pressure).
Sort Method: external merge -> work_mem too low.
- estimate-vs-actual row gap of 10x+ -> stale statistics, run
ANALYZE.
- Rank findings by measured impact (actual rows/buffers/time), never by the estimated cost percentage.
- Enable
pg_stat_statements; rank by total_exec_time (aggregate cost) and mean_exec_time (worst per-call); pg_stat_statements_reset() after a fix to re-measure.
- Autovacuum handles most tables; tune per-table for high churn and
ANALYZE after a bulk change:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05, autovacuum_analyze_scale_factor = 0.02);
ANALYZE orders;
work_mem is per sort/hash node, not per connection - keep work_mem * max_connections under ~25% of RAM or sorts spill to disk.
- A prepared statement can lock in a generic plan that hurts skewed values; if a prepared query degrades, force per-value planning (
plan_cache_mode = force_custom_plan).
Connections and pooling
- Each backend is a real process (~1-3MB) - always pool (PgBouncer or built-in). Rule of thumb
pool_size ~= cores*2; a few dozen real connections serve hundreds of clients.
- Transaction-mode pooling is the default. Session mode is required only for features bound to one backend: server-side prepared statements, temp tables, session GUCs, session advisory locks.
- Size
max_connections to RAM (100-200), not to peak client count - that is the pooler's job, and work_mem * max_connections must stay bounded.
- Behind a transaction pooler, disable driver-side prepared statements: Npgsql
Max Auto Prepare=0 (the ORM-side skill covers the EF Core wiring), postgres.js { prepare: false }, JDBC prepareThreshold=0.
Full-text search
LIKE '%term%' cannot use an index. Use a stored tsvector column + GIN + @@ - the working recipe (generated column, index, query operators) is in references/full-text-search.md.
RLS policy performance
Only when RLS is the tenancy mechanism (policy basics are the data-layer security skill's): make policy functions evaluate once per query instead of per row, and index the column every policy filters on - the patterns are in references/rls-performance.md.
1---2name: postgres3description: PostgreSQL engine specialist - the Postgres-specific delta on top of the cross-engine database-conventions hub: identifier folding and idempotent DDL, index-type selection (B-tree/GIN/GiST/BRIN/hash), JSONB and full-text indexing, SARGable predicate rewrites, the planner (EXPLAIN ANALYZE, pg_stat_statements, autovacuum/ANALYZE, work_mem), connection pooling modes, and array-batching/ON CONFLICT/COPY. Load for any hand-written Postgres SQL, an .sql file on a Postgres project, an EXPLAIN plan, a slow query, or an index/pooling decision. Not the cross-engine schema/transaction rules (-> database-conventions), the ORM side (-> dotnet-data-access), or another engine's SQL. Companions: database-conventions (cross-engine hub - load first), database-security (RLS/privileges), dotnet-data-access (the EF Core / ORM side).4---56# postgres (engine specialist)78The Postgres-specific layer. **Cross-engine conventions - schema design, migrations, indexing and transaction rules, connection handling - are the cross-engine database hub's; load that hub first where the install has it, and do not restate it here.** RLS basics and least-privilege logins are the data-layer security skill's; the .NET/EF Core side is the ORM-side skill's (EF Core / Dapper). This file is only what changes *because the engine is Postgres*, and stands on its own when the hub is absent.910## Schema and types1112- Keep every identifier lowercase `snake_case` and unquoted. Postgres folds unquoted identifiers to lowercase; a quoted mixed-case name (`"firstName"`) must be quoted forever and breaks ORMs and tools. Inheriting mixed-case? Wrap a `snake_case` view as a compatibility layer.13- `ADD CONSTRAINT IF NOT EXISTS` does not exist in Postgres - it is a syntax error. Guard idempotent constraint DDL with a `pg_constraint` check:1415```sql16DO $$ BEGIN17 IF NOT EXISTS (SELECT 1 FROM pg_constraint18 WHERE conname = 'profiles_owner_unique' AND conrelid = 'public.profiles'::regclass)19 THEN ALTER TABLE public.profiles ADD CONSTRAINT profiles_owner_unique UNIQUE (owner_id);20 END IF;21END $$;22```2324- Growable value set: `TEXT` + `CHECK (col IN (...))`. A native `CREATE TYPE ... AS ENUM` only for a truly fixed set - adding a value needs `ALTER TYPE`, reordering is painful.25- Partition (`PARTITION BY RANGE`) once a table passes ~100M rows or is time-series with date-scoped reads: the planner prunes to relevant partitions, and dropping old data is an instant `DROP TABLE events_2023_01`, not a lock-heavy `DELETE` + `VACUUM`.26- Postgres never auto-indexes foreign-key columns. Every FK needs its own index or joins and `ON DELETE CASCADE` become full scans - audit with `pg_constraint` vs `pg_index`.2728## Indexing - match the type to the query2930| Access pattern | Index |31|---|---|32| `=`, `<`, `>`, `between`, `in`, `is null`, `order by` | B-tree (default) |33| `jsonb` containment, arrays, full-text `tsvector` | GIN |34| geometric / range types, nearest-neighbor (KNN) | GiST |35| huge naturally-ordered / append-only (e.g. `created_at`) | BRIN (10-100x smaller than B-tree) |36| pure equality, marginal win over B-tree | Hash |3738- Composite leftmost-prefix: an index on `(a, b)` serves `WHERE a` and `WHERE a AND b`, never `WHERE b` alone. (Equality-first / range-last column ordering is the cross-engine hub's.)39- A partial index is used only when the planner proves the query predicate implies the index `WHERE` - keep that predicate identical to the query's own condition, and beware parameterized queries that can't match a literal-based filter.40- JSONB: a B-tree cannot serve `@>`. Use `GIN`; default `jsonb_ops` covers all operators, `jsonb_path_ops` covers only `@>`, `@?`, `@@` (not the key-existence `?`/`?&`/`?|`) at ~half the size. For scalar-key equality use an expression index, not GIN:4142```sql43CREATE INDEX products_attrs_gin ON products USING GIN (attributes); -- @>, ?, ?&, ?|44CREATE INDEX products_brand_idx ON products ((attributes->>'brand')); -- attributes->>'brand' = 'Nike'45```4647## Queries and the planner4849- SARGability is engine-neutral and the cross-engine hub's (its SQL style reference carries the full section): leave the indexed column bare. The Postgres spellings of the trap:5051| Non-sargable | Rewrite |52|---|---|53| `EXTRACT(YEAR FROM d) = 2026` | `d >= '2026-01-01' AND d < '2027-01-01'` |54| `date_trunc('day', ts) = :d` | `ts >= :d AND ts < :d + INTERVAL '1 day'` |55| `id::text = '42'` (cast on the column) | `id = 42` |5657- Must filter on a function (e.g. case-insensitive email)? The escape hatch is a matching expression index: `CREATE INDEX ON users ((lower(email)))` then `WHERE lower(email) = :v`.58- When only existence matters, use `EXISTS` (semi-join) not a join - a join on a non-unique key multiplies rows, and a predicate on the right table's columns in `WHERE` silently turns a `LEFT JOIN` into an inner join.59- Rewrite `OR` across different columns as `UNION ALL` branches so each branch can seek. Avoid the catch-all `col = :p OR :p IS NULL` on hot paths - use a query per shape.60- Batch instead of N+1: `WHERE user_id = ANY($1::bigint[])`, one round trip, not N.61- Atomic upsert closes the check-then-insert race:6263```sql64INSERT INTO settings (user_id, key, value) VALUES (123,'theme','dark')65 ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();66INSERT INTO page_views (page_id, user_id) VALUES (1,123) ON CONFLICT DO NOTHING;67```6869- Bulk load: multi-row `INSERT ... VALUES (...),(...)` (~1000 rows/statement) over per-row; `COPY` for large imports (fastest path).7071## Read-path diagnostics7273- `EXPLAIN (ANALYZE, BUFFERS)` is the primary tool - it runs the query and shows real timing and IO. Read for:74 - `Seq Scan` on a large table -> missing index.75 - high `Rows Removed by Filter` -> poor selectivity.76 - `Buffers: read >> hit` -> not cached (memory pressure).77 - `Sort Method: external merge` -> `work_mem` too low.78 - estimate-vs-actual row gap of 10x+ -> stale statistics, run `ANALYZE`.79- Rank findings by measured impact (actual rows/buffers/time), never by the estimated cost percentage.80- Enable `pg_stat_statements`; rank by `total_exec_time` (aggregate cost) and `mean_exec_time` (worst per-call); `pg_stat_statements_reset()` after a fix to re-measure.81- Autovacuum handles most tables; tune per-table for high churn and `ANALYZE` after a bulk change:8283```sql84ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05, autovacuum_analyze_scale_factor = 0.02);85ANALYZE orders;86```8788- `work_mem` is per sort/hash node, not per connection - keep `work_mem * max_connections` under ~25% of RAM or sorts spill to disk.89- A prepared statement can lock in a generic plan that hurts skewed values; if a prepared query degrades, force per-value planning (`plan_cache_mode = force_custom_plan`).9091## Connections and pooling9293- Each backend is a real process (~1-3MB) - always pool (PgBouncer or built-in). Rule of thumb `pool_size ~= cores*2`; a few dozen real connections serve hundreds of clients.94- Transaction-mode pooling is the default. Session mode is required only for features bound to one backend: server-side prepared statements, temp tables, session GUCs, session advisory locks.95- Size `max_connections` to RAM (100-200), not to peak client count - that is the pooler's job, and `work_mem * max_connections` must stay bounded.96- Behind a transaction pooler, disable driver-side prepared statements: Npgsql `Max Auto Prepare=0` (the ORM-side skill covers the EF Core wiring), postgres.js `{ prepare: false }`, JDBC `prepareThreshold=0`.9798## Full-text search99100`LIKE '%term%'` cannot use an index. Use a stored `tsvector` column + GIN + `@@` - the working recipe (generated column, index, query operators) is in `references/full-text-search.md`.101102## RLS policy performance103104Only when RLS is the tenancy mechanism (policy *basics* are the data-layer security skill's): make policy functions evaluate once per query instead of per row, and index the column every policy filters on - the patterns are in `references/rls-performance.md`.