PostgreSQL
Purpose
Operate PostgreSQL well: understand what MVCC costs you, why the table is bloated, why the connection count matters more than you think, and how to change a large table without locking it.
When to Use
- Designing schemas or indexes for Postgres.
- Diagnosing bloat, slow queries, or lock contention.
- Configuring connection pooling and replication.
- Running a migration on a large table without downtime.
Capabilities
- Index types: B-tree, GIN, GiST, BRIN, partial, expression, covering.
- MVCC, dead tuples, autovacuum tuning, and transaction ID wraparound.
- Connection pooling with PgBouncer and the pooling modes.
- Partitioning: declarative range and list partitions.
- JSONB indexing and query patterns.
- Streaming replication, replicas, and replication lag.
Inputs
- The schema, the table sizes, and the query patterns.
pg_stat_statements output for the slow-query question.
- Server configuration and Postgres version.
Outputs
- Indexes that match the access patterns, with no unused ones.
- Autovacuum settings appropriate to the write volume.
- Migrations that acquire only brief locks.
Workflow
- Find the real slow queries —
pg_stat_statements ordered by total time, not by mean. A 30ms query executed a million times costs more than a 4-second query run once.
- Index for the access pattern — Equality columns first in a composite index, then the range or sort column. Add partial indexes for the filters that dominate.
- Watch the dead tuples — Every
UPDATE writes a new row version and leaves the old one dead. A hot table with default autovacuum settings will bloat and slow down.
- Pool the connections — Each Postgres connection is a process with real memory cost. Beyond a few hundred, performance degrades. PgBouncer in transaction mode is the standard answer.
- Migrate without locking —
CREATE INDEX CONCURRENTLY. Add columns nullable, backfill in batches, then set defaults and constraints with NOT VALID followed by VALIDATE.
- Set a lock timeout — Before any DDL:
SET lock_timeout = '3s'. A migration that waits behind a long transaction will queue every subsequent query behind itself.
Best Practices
ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT <value> is safe on Postgres 11+ (no table rewrite). Adding a CHECK constraint or a foreign key still requires a scan — use NOT VALID, then VALIDATE CONSTRAINT separately.
- An unused index is a write-amplification tax paid on every insert and update.
pg_stat_user_indexes shows which have never been scanned. Drop them.
- Autovacuum defaults are tuned for a small database. On a high-write table, lower
autovacuum_vacuum_scale_factor for that table specifically.
idle in transaction connections hold locks and block vacuum indefinitely. Set idle_in_transaction_session_timeout.
- PgBouncer in transaction mode breaks prepared statements,
LISTEN/NOTIFY, and session-level SET. Know this before adopting it, not after.
- Replicas serve read traffic but lag. A read-after-write on a replica will see stale data — route the read to the primary or accept it.
Examples
A migration on a large table that takes no meaningful lock:
-- Adding a NOT NULL column with a default: safe and instant on PG 11+.
ALTER TABLE orders ADD COLUMN currency text NOT NULL DEFAULT 'USD';
-- A foreign key normally takes a lock and scans the whole table. Split it:
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers(id)
NOT VALID; -- instant: only new rows are checked
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;
-- scans, but takes only a SHARE UPDATE
-- EXCLUSIVE lock: writes continue.
-- An index without blocking writes:
SET lock_timeout = '3s'; -- do not queue behind a long transaction
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at DESC)
WHERE deleted_at IS NULL;
Finding the queries and the indexes that matter:
-- The queries that actually consume the database, by total time.
SELECT
substring(query, 1, 80) AS query,
calls,
round(total_exec_time::numeric, 0) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows / GREATEST(calls, 1) AS avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;
-- Indexes that have never been used: pure write overhead.
SELECT relname AS table, indexrelname AS index,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelid NOT IN (
SELECT conindid FROM pg_constraint WHERE contype IN ('p','u')
)
ORDER BY pg_relation_size(indexrelid) DESC;
Notes
CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and it can fail leaving an invalid index behind. Check pg_index.indisvalid afterwards and drop any invalid index before retrying.
- Transaction ID wraparound is the failure mode that takes a Postgres cluster fully offline. It only happens when autovacuum has been failing for a long time and being ignored — monitor
age(datfrozenxid).
- JSONB is excellent for genuinely schemaless data and a poor substitute for columns. A JSONB field that every query filters on should be a column with an index.
1---2name: postgres3description: Use when working with PostgreSQL specifically. Covers indexing, MVCC and vacuum, connection pooling, partitioning, JSONB, replication, and the operational realities that separate Postgres from generic SQL.4---56# PostgreSQL78## Purpose910Operate PostgreSQL well: understand what MVCC costs you, why the table is bloated, why the connection count matters more than you think, and how to change a large table without locking it.1112## When to Use1314- Designing schemas or indexes for Postgres.15- Diagnosing bloat, slow queries, or lock contention.16- Configuring connection pooling and replication.17- Running a migration on a large table without downtime.1819## Capabilities2021- Index types: B-tree, GIN, GiST, BRIN, partial, expression, covering.22- MVCC, dead tuples, autovacuum tuning, and transaction ID wraparound.23- Connection pooling with PgBouncer and the pooling modes.24- Partitioning: declarative range and list partitions.25- JSONB indexing and query patterns.26- Streaming replication, replicas, and replication lag.2728## Inputs2930- The schema, the table sizes, and the query patterns.31- `pg_stat_statements` output for the slow-query question.32- Server configuration and Postgres version.3334## Outputs3536- Indexes that match the access patterns, with no unused ones.37- Autovacuum settings appropriate to the write volume.38- Migrations that acquire only brief locks.3940## Workflow41421. **Find the real slow queries** — `pg_stat_statements` ordered by total time, not by mean. A 30ms query executed a million times costs more than a 4-second query run once.432. **Index for the access pattern** — Equality columns first in a composite index, then the range or sort column. Add partial indexes for the filters that dominate.443. **Watch the dead tuples** — Every `UPDATE` writes a new row version and leaves the old one dead. A hot table with default autovacuum settings will bloat and slow down.454. **Pool the connections** — Each Postgres connection is a process with real memory cost. Beyond a few hundred, performance degrades. PgBouncer in transaction mode is the standard answer.465. **Migrate without locking** — `CREATE INDEX CONCURRENTLY`. Add columns nullable, backfill in batches, then set defaults and constraints with `NOT VALID` followed by `VALIDATE`.476. **Set a lock timeout** — Before any DDL: `SET lock_timeout = '3s'`. A migration that waits behind a long transaction will queue every subsequent query behind itself.4849## Best Practices5051- `ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT <value>` is safe on Postgres 11+ (no table rewrite). Adding a `CHECK` constraint or a foreign key still requires a scan — use `NOT VALID`, then `VALIDATE CONSTRAINT` separately.52- An unused index is a write-amplification tax paid on every insert and update. `pg_stat_user_indexes` shows which have never been scanned. Drop them.53- Autovacuum defaults are tuned for a small database. On a high-write table, lower `autovacuum_vacuum_scale_factor` for that table specifically.54- `idle in transaction` connections hold locks and block vacuum indefinitely. Set `idle_in_transaction_session_timeout`.55- PgBouncer in transaction mode breaks prepared statements, `LISTEN/NOTIFY`, and session-level `SET`. Know this before adopting it, not after.56- Replicas serve read traffic but lag. A read-after-write on a replica will see stale data — route the read to the primary or accept it.5758## Examples5960**A migration on a large table that takes no meaningful lock:**6162```sql63-- Adding a NOT NULL column with a default: safe and instant on PG 11+.64ALTER TABLE orders ADD COLUMN currency text NOT NULL DEFAULT 'USD';6566-- A foreign key normally takes a lock and scans the whole table. Split it:67ALTER TABLE orders68 ADD CONSTRAINT orders_customer_fk69 FOREIGN KEY (customer_id) REFERENCES customers(id)70 NOT VALID; -- instant: only new rows are checked7172ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;73 -- scans, but takes only a SHARE UPDATE74 -- EXCLUSIVE lock: writes continue.7576-- An index without blocking writes:77SET lock_timeout = '3s'; -- do not queue behind a long transaction78CREATE INDEX CONCURRENTLY idx_orders_customer_created79 ON orders (customer_id, created_at DESC)80 WHERE deleted_at IS NULL;81```8283**Finding the queries and the indexes that matter:**8485```sql86-- The queries that actually consume the database, by total time.87SELECT88 substring(query, 1, 80) AS query,89 calls,90 round(total_exec_time::numeric, 0) AS total_ms,91 round(mean_exec_time::numeric, 2) AS mean_ms,92 rows / GREATEST(calls, 1) AS avg_rows93FROM pg_stat_statements94ORDER BY total_exec_time DESC95LIMIT 15;9697-- Indexes that have never been used: pure write overhead.98SELECT relname AS table, indexrelname AS index,99 pg_size_pretty(pg_relation_size(indexrelid)) AS size100FROM pg_stat_user_indexes101WHERE idx_scan = 0 AND indexrelid NOT IN (102 SELECT conindid FROM pg_constraint WHERE contype IN ('p','u')103)104ORDER BY pg_relation_size(indexrelid) DESC;105```106107## Notes108109- `CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and it can fail leaving an invalid index behind. Check `pg_index.indisvalid` afterwards and drop any invalid index before retrying.110- Transaction ID wraparound is the failure mode that takes a Postgres cluster fully offline. It only happens when autovacuum has been failing for a long time and being ignored — monitor `age(datfrozenxid)`.111- JSONB is excellent for genuinely schemaless data and a poor substitute for columns. A JSONB field that every query filters on should be a column with an index.