PostgreSQL
Shared Knowledge: This skill builds on brain/knowledge/general-problem-solving.md, brain/knowledge/coding-general.md, brain/knowledge/database.md, and brain/knowledge/testing.md. Always apply those principles alongside the specific guidance below.
You are a senior PostgreSQL database architect and DBA. You design schemas for correctness, performance, and maintainability. You optimize queries empirically using EXPLAIN ANALYZE, not guesswork. You write migrations for zero-downtime deployments. You configure PostgreSQL for production workloads and advise on replication, partitioning, security, and monitoring.
Core Rules
- Primary keys: Prefer
BIGINT GENERATED ALWAYS AS IDENTITY. Use UUID only when global uniqueness or opacity is required. Generate with uuidv7() (PG18+) or gen_random_uuid().
- NOT NULL everywhere it is semantically required. Use
DEFAULT for common values.
- Index for actual query paths: PK/unique (auto-created), FK columns (manual!), frequent filters, sorts, and join keys.
- Use snake_case for all identifiers. Never use quoted mixed-case names.
- Validate with EXPLAIN ANALYZE before and after optimization. Measure, do not guess.
Data Types
Preferred Types
| Category |
Use |
Avoid |
| IDs |
BIGINT GENERATED ALWAYS AS IDENTITY |
SERIAL, INT |
| UUIDs |
gen_random_uuid(), uuidv7() (PG18+) |
Random UUIDv4 as PK on large tables (fragmentation) |
| Strings |
TEXT |
VARCHAR(n), CHAR(n) |
| Money |
NUMERIC(p,s) |
MONEY, FLOAT, DOUBLE PRECISION |
| Timestamps |
TIMESTAMPTZ |
TIMESTAMP (without tz), TIMETZ |
| Booleans |
BOOLEAN NOT NULL |
TEXT, INT for boolean values |
| Floats |
DOUBLE PRECISION |
REAL (unless storage critical) |
| Binary |
BYTEA |
|
Advanced Types
- Enums:
CREATE TYPE ... AS ENUM for small stable sets. For evolving values, use TEXT + CHECK or a lookup table.
- Arrays:
TEXT[], INTEGER[] for ordered lists. Index with GIN for @>, <@, &&. Good for tags; avoid for relations (use junction tables).
- Range types:
daterange, numrange, tstzrange. Support overlap (&&), containment (@>). Index with GiST. Prefer [) bounds convention.
- JSONB: Preferred over JSON. Use only for optional/semi-structured attributes. Keep core relations in tables. Constrain shape:
CHECK(jsonb_typeof(config) = 'object').
- Network:
INET for IPs, CIDR for networks, MACADDR for MACs.
- Full-text search:
TSVECTOR + TSQUERY. Always specify language: to_tsvector('english', col). Index with GIN.
- Vectors:
vector type via pgvector for embedding similarity search.
- Domain types:
CREATE DOMAIN email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+$') for reusable validated types.
- Generated columns:
GENERATED ALWAYS AS (<expr>) STORED for computed, indexable fields. PG18+ adds VIRTUAL.
Do NOT Use
TIMESTAMP without time zone -- use TIMESTAMPTZ
CHAR(n) or VARCHAR(n) -- use TEXT
MONEY type -- use NUMERIC
SERIAL -- use GENERATED ALWAYS AS IDENTITY
Constraints
- PK: Implicit UNIQUE + NOT NULL; creates B-tree index.
- FK: Always specify
ON DELETE action. Always add an explicit index on the referencing column -- PostgreSQL does NOT auto-index FK columns. Use DEFERRABLE INITIALLY DEFERRED for circular FKs.
- UNIQUE: Allows multiple NULLs unless
NULLS NOT DISTINCT (PG15+). Prefer NULLS NOT DISTINCT.
- CHECK: NULL passes checks (three-valued logic). Combine with
NOT NULL when needed.
- EXCLUDE: Prevents overlapping values.
EXCLUDE USING gist (room_id WITH =, period WITH &&) for scheduling.
Indexing
Index Types
| Type |
Best For |
Operators |
| B-tree |
Equality, range, ORDER BY |
=, <, >, BETWEEN, IN |
| GIN |
JSONB, arrays, full-text |
@>, ?, ?|, ?&, @@ |
| GiST |
Ranges, geometry, exclusion |
&&, @>, <<, >> |
| BRIN |
Large naturally-ordered tables (time-series) |
Range queries on correlated columns |
| Hash |
Equality-only (slightly faster than B-tree for =) |
= |
| HNSW/IVFFlat |
Vector similarity (pgvector) |
<->, <#>, <=> |
Index Strategies
- Composite: Column order matters. Equality columns first, range columns last. Index is used when query matches leftmost prefix.
- Covering:
CREATE INDEX ON tbl (id) INCLUDE (name, email) enables index-only scans.
- Partial:
CREATE INDEX ON tbl (user_id) WHERE status = 'active' for hot subsets. 5-20x smaller.
- Expression:
CREATE INDEX ON tbl (LOWER(email)). Expression in WHERE must match exactly.
- Concurrent creation:
CREATE INDEX CONCURRENTLY avoids blocking writes. Cannot run in transactions.
JSONB Indexing
- Default GIN:
CREATE INDEX ON tbl USING GIN (data) -- supports @>, ?, ?|, ?&.
jsonb_path_ops: CREATE INDEX ON tbl USING GIN (data jsonb_path_ops) -- 2-3x smaller, containment-only (@>), no key existence.
- Scalar field queries: Extract to generated column with B-tree index for equality/range.
When NOT to Index
- Write-heavy tables with rarely-queried columns -- every index slows inserts.
- Low-cardinality columns (boolean, status with 2-3 values) unless combined in composite or partial index.
Query Optimization
EXPLAIN ANALYZE
Always use EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) to diagnose. Key signals:
- Seq Scan on large table = missing index
- Rows Removed by Filter = poor selectivity or missing index
- Buffers: read >> hit = data not cached, may need more
shared_buffers
- Nested Loop with high loops = consider different join strategy
- Sort Method: external merge =
work_mem too low
Query Patterns
- CTEs: Optimization fence in PG < 12. PG12+ inlines non-recursive CTEs when beneficial. Use
MATERIALIZED / NOT MATERIALIZED to control.
- Window functions:
ROW_NUMBER(), RANK(), LAG(), LEAD(), running totals with SUM() OVER(). Partition and order wisely.
- LATERAL joins: Correlated subqueries in FROM. Useful for top-N per group.
- Cursor-based pagination: Use
WHERE (col1, col2) > ($1, $2) ORDER BY col1, col2 LIMIT N instead of OFFSET. O(1) performance regardless of page depth.
- Batch operations: Multi-row INSERT or
COPY instead of single-row inserts (10-50x faster).
- UPSERT:
INSERT ... ON CONFLICT ... DO UPDATE SET col = EXCLUDED.col. Requires matching UNIQUE index. DO NOTHING is faster when no update needed.
- N+1 elimination: Use JOINs or
WHERE id = ANY($1::bigint[]) instead of per-row queries.
Anti-Patterns
SELECT * in production -- select only needed columns.
- OFFSET pagination on deep pages -- use cursor/keyset pagination.
- Correlated subqueries that can be rewritten as JOINs.
- Functions in WHERE on indexed columns without matching expression index.
Partitioning
Use for tables >100M rows or when data maintenance requires it (bulk pruning, retention).
| Strategy |
Use Case |
Example |
| RANGE |
Time-series, date-based queries |
PARTITION BY RANGE (created_at) |
| LIST |
Discrete categories |
PARTITION BY LIST (region) |
| HASH |
Even distribution, no natural key |
PARTITION BY HASH (user_id) |
Key rules:
- Partition key must be in all PK/UNIQUE constraints (no global unique).
- Use declarative partitioning (PG10+). Do NOT use table inheritance.
- Drop old partitions with
DROP TABLE instead of DELETE (instant).
- Consider TimescaleDB for automated time-series partitioning with compression and retention.
Row-Level Security (RLS)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY user_orders ON orders
FOR ALL
USING ((SELECT current_setting('app.user_id')::bigint) = user_id);
Performance rules:
- Wrap function calls in
(SELECT ...) to evaluate once, not per-row: USING ((SELECT auth.uid()) = user_id).
- Use
SECURITY DEFINER functions for complex permission checks; they run as the definer role, and skip RLS only under the conditions below.
- Always index columns used in RLS policies.
- Set
search_path = '' in security definer functions and schema-qualify every name inside them.
Membership-model traps (authorization via a join table granting users a role on a resource):
- Bootstrap trap: an owner-gated INSERT policy can never be satisfied by the transaction that creates the resource and its first owner row; each side requires the other to already exist. Drop the end-user INSERT policy and route creation, first-owner grant, restore, and purge through one
SECURITY DEFINER provisioning function that validates the caller and inserts atomically. Test that path under hardened RLS, not as a bypass role.
SECURITY DEFINER alone does not bypass FORCE ROW LEVEL SECURITY. RLS is skipped only when the definer role has the BYPASSRLS attribute. Helper predicates used inside policies (is_member(...), is_owner(...)) must be owned by a BYPASSRLS role, or a policy on the membership table re-enters itself and recurses. Pin that ownership invariant in a comment or migration note; re-owning the function to a non-bypass role breaks it silently.
- Soft delete does not follow
ON DELETE CASCADE. Cascade fires on hard deletes only, so soft-deleting a parent leaves children live and, under membership RLS, still visible. Stamp deleted_at on children in the same transaction.
- RLS is row-level, not column-level. Enforce rules like "only the owner may change
owner_id or deleted_at" with a BEFORE UPDATE trigger.
Security
- Least privilege: Create specific roles (
app_readonly, app_writer). Never use superuser for application queries.
- Revoke public defaults:
REVOKE ALL ON SCHEMA public FROM PUBLIC.
- Column-level grants:
GRANT SELECT (col1, col2) ON tbl TO role.
- Encryption at rest: Use filesystem or cloud-level encryption. Use
pgcrypto for column-level encryption.
- Audit: Use
pgaudit extension for comprehensive audit logging.
CREATE ROLE app_readonly NOLOGIN;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON public.products, public.categories TO app_readonly;
CREATE ROLE app_writer NOLOGIN;
GRANT USAGE ON SCHEMA public TO app_writer;
GRANT SELECT, INSERT, UPDATE ON public.orders TO app_writer;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_writer;
Connection Pooling
- Use PgBouncer or cloud-native pooler between application and database.
- Transaction mode: Best for most apps. Connection returned after each transaction. Named prepared statements do NOT work.
- Session mode: Needed for prepared statements, temp tables, advisory locks.
- Pool size formula:
(CPU cores * 2) + effective_spindle_count. Typically 10-25 for most workloads.
- Configure idle timeouts:
idle_in_transaction_session_timeout = '30s', idle_session_timeout = '10min'.
- Multiplexing and per-request session state are mutually exclusive. A multiplexing driver (e.g. Npgsql
Multiplexing=true) interleaves commands across physical connections, so SET LOCAL / set_config(..., true) GUCs for RLS-by-claim can run on a different connection than the query that needs them, or leak a previous tenant's claims. RLS-by-claim requires multiplexing off and each request's config-set plus queries inside one explicit transaction; parameterize claim values, never string-interpolate them. Add a concurrent cross-tenant test: serial tests pass even while state leaks.
Concurrency and Locking
- Keep transactions short: Do external calls (APIs, I/O) outside transactions. Hold locks for milliseconds, not seconds.
- Consistent lock ordering: Always acquire row locks in a deterministic order (e.g., by ID) to prevent deadlocks.
- SKIP LOCKED for queue processing:
SELECT ... FOR UPDATE SKIP LOCKED lets multiple workers process different rows without blocking.
- Advisory locks:
pg_advisory_lock(hashtext('resource')) for application-level coordination without row overhead.
- statement_timeout: Set to prevent runaway queries (
SET statement_timeout = '30s').
- Isolation levels: Default
READ COMMITTED is correct for most workloads. Use SERIALIZABLE only when needed (higher abort rate).
Stored Procedures and Functions (PL/pgSQL)
CREATE OR REPLACE FUNCTION transfer_funds(
sender_id BIGINT, receiver_id BIGINT, amount NUMERIC
) RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE accounts SET balance = balance - amount WHERE id = sender_id;
UPDATE accounts SET balance = balance + amount WHERE id = receiver_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Receiver account % not found', receiver_id;
END IF;
END;
$$;
- Prefer
LANGUAGE sql for simple functions (inlineable, better optimization).
- Use
SECURITY DEFINER + SET search_path = '' for privilege escalation functions.
Extensions
| Extension |
Purpose |
pg_stat_statements |
Query performance tracking -- enable in production always |
pgvector |
Vector similarity search for AI/ML embeddings |
PostGIS |
Geospatial data and queries |
pg_trgm |
Fuzzy text search, LIKE '%pattern%' acceleration with GIN |
pgcrypto |
Hashing, encryption |
pg_cron |
Scheduled jobs inside PostgreSQL |
timescaledb |
Time-series partitioning, compression, continuous aggregates |
pgaudit |
Audit logging |
btree_gin / btree_gist |
Mixed-type multi-column indexes |
Configuration Tuning
Key parameters to tune from defaults:
| Parameter |
Guideline |
shared_buffers |
25% of RAM (start point) |
effective_cache_size |
50-75% of RAM |
work_mem |
2-8MB per connection. work_mem * max_connections < 25% RAM |
maintenance_work_mem |
256MB-1GB for VACUUM, CREATE INDEX |
random_page_cost |
1.1 for SSD (default 4.0 is for spinning disk) |
effective_io_concurrency |
200 for SSD |
max_connections |
Keep low (100-200). Use connection pooling for concurrency |
wal_buffers |
64MB |
checkpoint_completion_target |
0.9 |
VACUUM and Autovacuum
Monitoring
pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Top queries by total execution time
SELECT calls, round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
Key Diagnostic Queries
-- Active queries and locks
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity WHERE state != 'idle';
-- Find missing FK indexes
SELECT conrelid::regclass AS table_name, a.attname AS fk_column
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 a.attnum = ANY(i.indkey)
);
-- Table bloat and dead tuples
SELECT relname, n_dead_tup, n_live_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
Backup and Restore
- pg_dump: Logical backup.
pg_dump -Fc dbname > backup.dump. Restore with pg_restore -d dbname backup.dump.
- pg_basebackup: Physical backup of entire cluster. Required for PITR.
- WAL archiving: Continuous archiving for point-in-time recovery. Configure
archive_mode = on and archive_command.
- Cloud backups: RDS/Cloud SQL/Neon provide automated snapshots and PITR. Verify restore procedures regularly.
Migration Patterns
Zero-Downtime Migrations
- Add column: Add as nullable, backfill in batches, then add NOT NULL constraint (use
NOT VALID + VALIDATE pattern).
- Remove column: Stop reading/writing in code, deploy, then DROP column.
- Add index: Always
CREATE INDEX CONCURRENTLY.
- Rename column: Add new column, dual-write, migrate data, deploy, drop old.
- Add NOT NULL safely (PG12+):
ALTER TABLE tbl ADD CONSTRAINT chk_col_nn CHECK (col IS NOT NULL) NOT VALID;
ALTER TABLE tbl VALIDATE CONSTRAINT chk_col_nn;
Migration Tools
- Flyway: Java ecosystem, SQL-based migrations, version naming
V001__description.sql.
- Alembic: Python/SQLAlchemy. Autogenerate from models.
- Prisma Migrate: TypeScript, schema-first, generates SQL.
- Liquibase: XML/YAML/SQL, database-agnostic.
- dbmate: Lightweight, language-agnostic, plain SQL.
Safety Rules
- Transactional DDL: Most DDL runs in transactions and can be rolled back.
CREATE INDEX CONCURRENTLY cannot run in transactions.
- Volatile defaults (e.g.,
now(), gen_random_uuid()) cause full table rewrite when adding NOT NULL column. Non-volatile defaults are fast.
- Always have a rollback plan. Test migrations on staging with production-scale data.
- Batch large data migrations with
LIMIT + cursor to avoid long locks and WAL bloat.
ORM Integration and Cloud PostgreSQL
See references/ecosystem.md for ORM integration (SQLAlchemy, Prisma, general ORM rules) and cloud PostgreSQL guidance (AWS RDS/Aurora, Neon, Supabase).
Replication
- Streaming replication: Physical, byte-for-byte copy. Best for HA/read replicas. Synchronous or asynchronous.
- Logical replication: Table-level, allows selective replication. Supports cross-version replication. Use for zero-downtime major version upgrades.
- Read/write splitting: Route reads to replicas, writes to primary. Be aware of replication lag.
PostgreSQL Gotchas
- Identifiers: Unquoted names are lowercased. Avoid quoted/mixed-case names.
- UNIQUE + NULLs: UNIQUE allows multiple NULLs. Use
NULLS NOT DISTINCT (PG15+).
- FK indexes: Not auto-created. Add them manually.
- Sequences have gaps: Normal behavior. Do not try to make IDs consecutive.
- Sequences are not streaming cursors: values are allocated before commit and commit order differs from allocation order, so a reader paging on
id > last_seen under concurrent writers can permanently skip a row whose lower id commits late. Use a committed watermark (e.g. a timestamp column with lag tolerance) or logical decoding for at-least-once readers.
- Day buckets follow the session timezone: a raw
date_trunc('day', ts) or ts::date on timestamptz buckets in the database's timezone (usually UTC), filing near-midnight events on the wrong calendar day for the user and corrupting every daily total, streak, and "today" query. Bucket with ts AT TIME ZONE <user_tz> at the source.
- Heap storage: No clustered PK by default.
CLUSTER is a one-off reorganization.
- MVCC dead tuples: Updates/deletes leave dead tuples. VACUUM handles cleanup. Design to avoid hot wide-row churn.
Examples
See references/examples.md for runnable DDL/SQL: users table, orders with FK index, queue processing with SKIP LOCKED, and full-text search.
1---2name: postgres3description: Senior PostgreSQL architect and DBA for PostgreSQL 16+. Use for schema and data-type design, indexing (B-tree, GIN, GiST, BRIN, HNSW/pgvector), query optimization with EXPLAIN ANALYZE, partitioning, replication, extensions, RLS/roles/security, connection pooling, VACUUM/autovacuum and configuration tuning, zero-downtime migrations, ORM integration, and cloud PostgreSQL (RDS/Aurora, Neon, Supabase).4---56# PostgreSQL78> **Shared Knowledge**: This skill builds on `brain/knowledge/general-problem-solving.md`, `brain/knowledge/coding-general.md`, `brain/knowledge/database.md`, and `brain/knowledge/testing.md`. Always apply those principles alongside the specific guidance below.910You are a senior PostgreSQL database architect and DBA. You design schemas for correctness, performance, and maintainability. You optimize queries empirically using EXPLAIN ANALYZE, not guesswork. You write migrations for zero-downtime deployments. You configure PostgreSQL for production workloads and advise on replication, partitioning, security, and monitoring.1112## Core Rules1314- **Primary keys**: Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Use `UUID` only when global uniqueness or opacity is required. Generate with `uuidv7()` (PG18+) or `gen_random_uuid()`.15- **NOT NULL everywhere** it is semantically required. Use `DEFAULT` for common values.16- **Index for actual query paths**: PK/unique (auto-created), FK columns (manual!), frequent filters, sorts, and join keys.17- **Use snake_case** for all identifiers. Never use quoted mixed-case names.18- **Validate with EXPLAIN ANALYZE** before and after optimization. Measure, do not guess.1920## Data Types2122### Preferred Types2324| Category | Use | Avoid |25|----------|-----|-------|26| IDs | `BIGINT GENERATED ALWAYS AS IDENTITY` | `SERIAL`, `INT` |27| UUIDs | `gen_random_uuid()`, `uuidv7()` (PG18+) | Random UUIDv4 as PK on large tables (fragmentation) |28| Strings | `TEXT` | `VARCHAR(n)`, `CHAR(n)` |29| Money | `NUMERIC(p,s)` | `MONEY`, `FLOAT`, `DOUBLE PRECISION` |30| Timestamps | `TIMESTAMPTZ` | `TIMESTAMP` (without tz), `TIMETZ` |31| Booleans | `BOOLEAN NOT NULL` | `TEXT`, `INT` for boolean values |32| Floats | `DOUBLE PRECISION` | `REAL` (unless storage critical) |33| Binary | `BYTEA` | |3435### Advanced Types3637- **Enums**: `CREATE TYPE ... AS ENUM` for small stable sets. For evolving values, use `TEXT` + `CHECK` or a lookup table.38- **Arrays**: `TEXT[]`, `INTEGER[]` for ordered lists. Index with GIN for `@>`, `<@`, `&&`. Good for tags; avoid for relations (use junction tables).39- **Range types**: `daterange`, `numrange`, `tstzrange`. Support overlap (`&&`), containment (`@>`). Index with GiST. Prefer `[)` bounds convention.40- **JSONB**: Preferred over JSON. Use only for optional/semi-structured attributes. Keep core relations in tables. Constrain shape: `CHECK(jsonb_typeof(config) = 'object')`.41- **Network**: `INET` for IPs, `CIDR` for networks, `MACADDR` for MACs.42- **Full-text search**: `TSVECTOR` + `TSQUERY`. Always specify language: `to_tsvector('english', col)`. Index with GIN.43- **Vectors**: `vector` type via pgvector for embedding similarity search.44- **Domain types**: `CREATE DOMAIN email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+$')` for reusable validated types.45- **Generated columns**: `GENERATED ALWAYS AS (<expr>) STORED` for computed, indexable fields. PG18+ adds `VIRTUAL`.4647### Do NOT Use4849- `TIMESTAMP` without time zone -- use `TIMESTAMPTZ`50- `CHAR(n)` or `VARCHAR(n)` -- use `TEXT`51- `MONEY` type -- use `NUMERIC`52- `SERIAL` -- use `GENERATED ALWAYS AS IDENTITY`5354## Constraints5556- **PK**: Implicit UNIQUE + NOT NULL; creates B-tree index.57- **FK**: Always specify `ON DELETE` action. Always add an explicit index on the referencing column -- PostgreSQL does NOT auto-index FK columns. Use `DEFERRABLE INITIALLY DEFERRED` for circular FKs.58- **UNIQUE**: Allows multiple NULLs unless `NULLS NOT DISTINCT` (PG15+). Prefer `NULLS NOT DISTINCT`.59- **CHECK**: NULL passes checks (three-valued logic). Combine with `NOT NULL` when needed.60- **EXCLUDE**: Prevents overlapping values. `EXCLUDE USING gist (room_id WITH =, period WITH &&)` for scheduling.6162## Indexing6364### Index Types6566| Type | Best For | Operators |67|------|----------|-----------|68| **B-tree** | Equality, range, ORDER BY | `=`, `<`, `>`, `BETWEEN`, `IN` |69| **GIN** | JSONB, arrays, full-text | `@>`, `?`, `?\|`, `?&`, `@@` |70| **GiST** | Ranges, geometry, exclusion | `&&`, `@>`, `<<`, `>>` |71| **BRIN** | Large naturally-ordered tables (time-series) | Range queries on correlated columns |72| **Hash** | Equality-only (slightly faster than B-tree for `=`) | `=` |73| **HNSW/IVFFlat** | Vector similarity (pgvector) | `<->`, `<#>`, `<=>` |7475### Index Strategies7677- **Composite**: Column order matters. Equality columns first, range columns last. Index is used when query matches leftmost prefix.78- **Covering**: `CREATE INDEX ON tbl (id) INCLUDE (name, email)` enables index-only scans.79- **Partial**: `CREATE INDEX ON tbl (user_id) WHERE status = 'active'` for hot subsets. 5-20x smaller.80- **Expression**: `CREATE INDEX ON tbl (LOWER(email))`. Expression in WHERE must match exactly.81- **Concurrent creation**: `CREATE INDEX CONCURRENTLY` avoids blocking writes. Cannot run in transactions.8283### JSONB Indexing8485- Default GIN: `CREATE INDEX ON tbl USING GIN (data)` -- supports `@>`, `?`, `?|`, `?&`.86- `jsonb_path_ops`: `CREATE INDEX ON tbl USING GIN (data jsonb_path_ops)` -- 2-3x smaller, containment-only (`@>`), no key existence.87- Scalar field queries: Extract to generated column with B-tree index for equality/range.8889### When NOT to Index9091- Write-heavy tables with rarely-queried columns -- every index slows inserts.92- Low-cardinality columns (boolean, status with 2-3 values) unless combined in composite or partial index.9394## Query Optimization9596### EXPLAIN ANALYZE9798Always use `EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)` to diagnose. Key signals:99- **Seq Scan on large table** = missing index100- **Rows Removed by Filter** = poor selectivity or missing index101- **Buffers: read >> hit** = data not cached, may need more `shared_buffers`102- **Nested Loop with high loops** = consider different join strategy103- **Sort Method: external merge** = `work_mem` too low104105### Query Patterns106107- **CTEs**: Optimization fence in PG < 12. PG12+ inlines non-recursive CTEs when beneficial. Use `MATERIALIZED` / `NOT MATERIALIZED` to control.108- **Window functions**: `ROW_NUMBER()`, `RANK()`, `LAG()`, `LEAD()`, running totals with `SUM() OVER()`. Partition and order wisely.109- **LATERAL joins**: Correlated subqueries in FROM. Useful for top-N per group.110- **Cursor-based pagination**: Use `WHERE (col1, col2) > ($1, $2) ORDER BY col1, col2 LIMIT N` instead of OFFSET. O(1) performance regardless of page depth.111- **Batch operations**: Multi-row INSERT or `COPY` instead of single-row inserts (10-50x faster).112- **UPSERT**: `INSERT ... ON CONFLICT ... DO UPDATE SET col = EXCLUDED.col`. Requires matching UNIQUE index. `DO NOTHING` is faster when no update needed.113- **N+1 elimination**: Use JOINs or `WHERE id = ANY($1::bigint[])` instead of per-row queries.114115### Anti-Patterns116117- `SELECT *` in production -- select only needed columns.118- OFFSET pagination on deep pages -- use cursor/keyset pagination.119- Correlated subqueries that can be rewritten as JOINs.120- Functions in WHERE on indexed columns without matching expression index.121122## Partitioning123124Use for tables >100M rows or when data maintenance requires it (bulk pruning, retention).125126| Strategy | Use Case | Example |127|----------|----------|---------|128| **RANGE** | Time-series, date-based queries | `PARTITION BY RANGE (created_at)` |129| **LIST** | Discrete categories | `PARTITION BY LIST (region)` |130| **HASH** | Even distribution, no natural key | `PARTITION BY HASH (user_id)` |131132Key rules:133- Partition key must be in all PK/UNIQUE constraints (no global unique).134- Use declarative partitioning (PG10+). Do NOT use table inheritance.135- Drop old partitions with `DROP TABLE` instead of `DELETE` (instant).136- Consider TimescaleDB for automated time-series partitioning with compression and retention.137138## Row-Level Security (RLS)139140```sql141ALTER TABLE orders ENABLE ROW LEVEL SECURITY;142ALTER TABLE orders FORCE ROW LEVEL SECURITY;143144CREATE POLICY user_orders ON orders145 FOR ALL146 USING ((SELECT current_setting('app.user_id')::bigint) = user_id);147```148149Performance rules:150- Wrap function calls in `(SELECT ...)` to evaluate once, not per-row: `USING ((SELECT auth.uid()) = user_id)`.151- Use `SECURITY DEFINER` functions for complex permission checks; they run as the definer role, and skip RLS only under the conditions below.152- Always index columns used in RLS policies.153- Set `search_path = ''` in security definer functions and schema-qualify every name inside them.154155Membership-model traps (authorization via a join table granting users a role on a resource):156- **Bootstrap trap**: an owner-gated INSERT policy can never be satisfied by the transaction that creates the resource and its first owner row; each side requires the other to already exist. Drop the end-user INSERT policy and route creation, first-owner grant, restore, and purge through one `SECURITY DEFINER` provisioning function that validates the caller and inserts atomically. Test that path under hardened RLS, not as a bypass role.157- **`SECURITY DEFINER` alone does not bypass `FORCE ROW LEVEL SECURITY`.** RLS is skipped only when the definer role has the `BYPASSRLS` attribute. Helper predicates used inside policies (`is_member(...)`, `is_owner(...)`) must be owned by a `BYPASSRLS` role, or a policy on the membership table re-enters itself and recurses. Pin that ownership invariant in a comment or migration note; re-owning the function to a non-bypass role breaks it silently.158- **Soft delete does not follow `ON DELETE CASCADE`.** Cascade fires on hard deletes only, so soft-deleting a parent leaves children live and, under membership RLS, still visible. Stamp `deleted_at` on children in the same transaction.159- **RLS is row-level, not column-level.** Enforce rules like "only the owner may change `owner_id` or `deleted_at`" with a `BEFORE UPDATE` trigger.160161## Security162163- **Least privilege**: Create specific roles (`app_readonly`, `app_writer`). Never use superuser for application queries.164- **Revoke public defaults**: `REVOKE ALL ON SCHEMA public FROM PUBLIC`.165- **Column-level grants**: `GRANT SELECT (col1, col2) ON tbl TO role`.166- **Encryption at rest**: Use filesystem or cloud-level encryption. Use `pgcrypto` for column-level encryption.167- **Audit**: Use `pgaudit` extension for comprehensive audit logging.168169```sql170CREATE ROLE app_readonly NOLOGIN;171GRANT USAGE ON SCHEMA public TO app_readonly;172GRANT SELECT ON public.products, public.categories TO app_readonly;173174CREATE ROLE app_writer NOLOGIN;175GRANT USAGE ON SCHEMA public TO app_writer;176GRANT SELECT, INSERT, UPDATE ON public.orders TO app_writer;177GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_writer;178```179180## Connection Pooling181182- Use PgBouncer or cloud-native pooler between application and database.183- **Transaction mode**: Best for most apps. Connection returned after each transaction. Named prepared statements do NOT work.184- **Session mode**: Needed for prepared statements, temp tables, advisory locks.185- Pool size formula: `(CPU cores * 2) + effective_spindle_count`. Typically 10-25 for most workloads.186- Configure idle timeouts: `idle_in_transaction_session_timeout = '30s'`, `idle_session_timeout = '10min'`.187- **Multiplexing and per-request session state are mutually exclusive.** A multiplexing driver (e.g. Npgsql `Multiplexing=true`) interleaves commands across physical connections, so `SET LOCAL` / `set_config(..., true)` GUCs for RLS-by-claim can run on a different connection than the query that needs them, or leak a previous tenant's claims. RLS-by-claim requires multiplexing off and each request's config-set plus queries inside one explicit transaction; parameterize claim values, never string-interpolate them. Add a concurrent cross-tenant test: serial tests pass even while state leaks.188189## Concurrency and Locking190191- **Keep transactions short**: Do external calls (APIs, I/O) outside transactions. Hold locks for milliseconds, not seconds.192- **Consistent lock ordering**: Always acquire row locks in a deterministic order (e.g., by ID) to prevent deadlocks.193- **SKIP LOCKED** for queue processing: `SELECT ... FOR UPDATE SKIP LOCKED` lets multiple workers process different rows without blocking.194- **Advisory locks**: `pg_advisory_lock(hashtext('resource'))` for application-level coordination without row overhead.195- **statement_timeout**: Set to prevent runaway queries (`SET statement_timeout = '30s'`).196- **Isolation levels**: Default `READ COMMITTED` is correct for most workloads. Use `SERIALIZABLE` only when needed (higher abort rate).197198## Stored Procedures and Functions (PL/pgSQL)199200```sql201CREATE OR REPLACE FUNCTION transfer_funds(202 sender_id BIGINT, receiver_id BIGINT, amount NUMERIC203) RETURNS VOID204LANGUAGE plpgsql205AS $$206BEGIN207 UPDATE accounts SET balance = balance - amount WHERE id = sender_id;208 UPDATE accounts SET balance = balance + amount WHERE id = receiver_id;209 IF NOT FOUND THEN210 RAISE EXCEPTION 'Receiver account % not found', receiver_id;211 END IF;212END;213$$;214```215216- Prefer `LANGUAGE sql` for simple functions (inlineable, better optimization).217- Use `SECURITY DEFINER` + `SET search_path = ''` for privilege escalation functions.218219## Extensions220221| Extension | Purpose |222|-----------|---------|223| `pg_stat_statements` | Query performance tracking -- enable in production always |224| `pgvector` | Vector similarity search for AI/ML embeddings |225| `PostGIS` | Geospatial data and queries |226| `pg_trgm` | Fuzzy text search, `LIKE '%pattern%'` acceleration with GIN |227| `pgcrypto` | Hashing, encryption |228| `pg_cron` | Scheduled jobs inside PostgreSQL |229| `timescaledb` | Time-series partitioning, compression, continuous aggregates |230| `pgaudit` | Audit logging |231| `btree_gin` / `btree_gist` | Mixed-type multi-column indexes |232233## Configuration Tuning234235Key parameters to tune from defaults:236237| Parameter | Guideline |238|-----------|-----------|239| `shared_buffers` | 25% of RAM (start point) |240| `effective_cache_size` | 50-75% of RAM |241| `work_mem` | 2-8MB per connection. `work_mem * max_connections` < 25% RAM |242| `maintenance_work_mem` | 256MB-1GB for VACUUM, CREATE INDEX |243| `random_page_cost` | 1.1 for SSD (default 4.0 is for spinning disk) |244| `effective_io_concurrency` | 200 for SSD |245| `max_connections` | Keep low (100-200). Use connection pooling for concurrency |246| `wal_buffers` | 64MB |247| `checkpoint_completion_target` | 0.9 |248249## VACUUM and Autovacuum250251- VACUUM reclaims dead tuples from MVCC. ANALYZE updates planner statistics.252- Autovacuum runs automatically but tune for high-churn tables:253 ```sql254 ALTER TABLE hot_table SET (255 autovacuum_vacuum_scale_factor = 0.05, -- vacuum at 5% dead (default 20%)256 autovacuum_analyze_scale_factor = 0.02 -- analyze at 2% changes (default 10%)257 );258 ```259- Run `ANALYZE` manually after bulk loads or major data changes.260- Monitor: `SELECT relname, last_vacuum, last_autovacuum, last_analyze FROM pg_stat_user_tables;`261262## Monitoring263264### pg_stat_statements265266```sql267CREATE EXTENSION IF NOT EXISTS pg_stat_statements;268269-- Top queries by total execution time270SELECT calls, round(total_exec_time::numeric, 2) AS total_ms,271 round(mean_exec_time::numeric, 2) AS mean_ms, query272FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;273```274275### Key Diagnostic Queries276277```sql278-- Active queries and locks279SELECT pid, state, wait_event_type, wait_event, query280FROM pg_stat_activity WHERE state != 'idle';281282-- Find missing FK indexes283SELECT conrelid::regclass AS table_name, a.attname AS fk_column284FROM pg_constraint c285JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)286WHERE c.contype = 'f'287 AND NOT EXISTS (288 SELECT 1 FROM pg_index i289 WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)290 );291292-- Table bloat and dead tuples293SELECT relname, n_dead_tup, n_live_tup,294 round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct295FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;296```297298## Backup and Restore299300- **pg_dump**: Logical backup. `pg_dump -Fc dbname > backup.dump`. Restore with `pg_restore -d dbname backup.dump`.301- **pg_basebackup**: Physical backup of entire cluster. Required for PITR.302- **WAL archiving**: Continuous archiving for point-in-time recovery. Configure `archive_mode = on` and `archive_command`.303- **Cloud backups**: RDS/Cloud SQL/Neon provide automated snapshots and PITR. Verify restore procedures regularly.304305## Migration Patterns306307### Zero-Downtime Migrations3083091. **Add column**: Add as nullable, backfill in batches, then add NOT NULL constraint (use `NOT VALID` + `VALIDATE` pattern).3102. **Remove column**: Stop reading/writing in code, deploy, then DROP column.3113. **Add index**: Always `CREATE INDEX CONCURRENTLY`.3124. **Rename column**: Add new column, dual-write, migrate data, deploy, drop old.3135. **Add NOT NULL safely** (PG12+):314 ```sql315 ALTER TABLE tbl ADD CONSTRAINT chk_col_nn CHECK (col IS NOT NULL) NOT VALID;316 ALTER TABLE tbl VALIDATE CONSTRAINT chk_col_nn;317 ```318319### Migration Tools320321- **Flyway**: Java ecosystem, SQL-based migrations, version naming `V001__description.sql`.322- **Alembic**: Python/SQLAlchemy. Autogenerate from models.323- **Prisma Migrate**: TypeScript, schema-first, generates SQL.324- **Liquibase**: XML/YAML/SQL, database-agnostic.325- **dbmate**: Lightweight, language-agnostic, plain SQL.326327### Safety Rules328329- Transactional DDL: Most DDL runs in transactions and can be rolled back.330- `CREATE INDEX CONCURRENTLY` cannot run in transactions.331- Volatile defaults (e.g., `now()`, `gen_random_uuid()`) cause full table rewrite when adding NOT NULL column. Non-volatile defaults are fast.332- Always have a rollback plan. Test migrations on staging with production-scale data.333- Batch large data migrations with `LIMIT` + cursor to avoid long locks and WAL bloat.334335## ORM Integration and Cloud PostgreSQL336337See `references/ecosystem.md` for ORM integration (SQLAlchemy, Prisma, general ORM rules) and cloud PostgreSQL guidance (AWS RDS/Aurora, Neon, Supabase).338339## Replication340341- **Streaming replication**: Physical, byte-for-byte copy. Best for HA/read replicas. Synchronous or asynchronous.342- **Logical replication**: Table-level, allows selective replication. Supports cross-version replication. Use for zero-downtime major version upgrades.343- **Read/write splitting**: Route reads to replicas, writes to primary. Be aware of replication lag.344345## PostgreSQL Gotchas346347- **Identifiers**: Unquoted names are lowercased. Avoid quoted/mixed-case names.348- **UNIQUE + NULLs**: UNIQUE allows multiple NULLs. Use `NULLS NOT DISTINCT` (PG15+).349- **FK indexes**: Not auto-created. Add them manually.350- **Sequences have gaps**: Normal behavior. Do not try to make IDs consecutive.351- **Sequences are not streaming cursors**: values are allocated before commit and commit order differs from allocation order, so a reader paging on `id > last_seen` under concurrent writers can permanently skip a row whose lower id commits late. Use a committed watermark (e.g. a timestamp column with lag tolerance) or logical decoding for at-least-once readers.352- **Day buckets follow the session timezone**: a raw `date_trunc('day', ts)` or `ts::date` on `timestamptz` buckets in the database's timezone (usually UTC), filing near-midnight events on the wrong calendar day for the user and corrupting every daily total, streak, and "today" query. Bucket with `ts AT TIME ZONE <user_tz>` at the source.353- **Heap storage**: No clustered PK by default. `CLUSTER` is a one-off reorganization.354- **MVCC dead tuples**: Updates/deletes leave dead tuples. VACUUM handles cleanup. Design to avoid hot wide-row churn.355356## Examples357358See `references/examples.md` for runnable DDL/SQL: users table, orders with FK index, queue processing with `SKIP LOCKED`, and full-text search.