PostgreSQL Operations - Street-Level Ops¶
What experienced PostgreSQL operators know that tutorials don't teach.
Quick Diagnosis Commands¶
# Connect as postgres superuser
sudo -u postgres psql
psql -U postgres -h 127.0.0.1 -p 5432 -d mydb
# Active queries — what's running right now
SELECT pid, now() - query_start AS duration, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;
# Blocking queries — find locks
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.cardinality(pg_blocking_pids(blocked.pid)) > 0;
# Simpler lock query (pg 9.6+)
SELECT pid, pg_blocking_pids(pid) AS blocked_by, query, state
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
# Kill a query (graceful)
SELECT pg_cancel_backend(<pid>);
-- Kill only the query, leaves connection open
# Kill a connection (forceful)
SELECT pg_terminate_backend(<pid>);
# Table sizes
SELECT
schemaname, tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS index_size
FROM pg_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
# Database sizes
SELECT datname, pg_size_pretty(pg_database_size(datname)) FROM pg_database ORDER BY 2 DESC;
# Connection counts
SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname ORDER BY 2 DESC;
SELECT count(*) FROM pg_stat_activity WHERE state = 'idle';
SELECT count(*) FROM pg_stat_activity WHERE state = 'idle in transaction';
# Check autovacuum activity
SELECT schemaname, relname, last_autovacuum, last_autoanalyze, n_dead_tup, n_live_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
# Replication lag (on primary)
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
# Check WAL position
SELECT pg_current_wal_lsn(); -- current WAL write position (primary)
SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(); -- replica
SELECT pg_is_in_recovery(); -- true = this is a replica
# Index usage — find unused indexes
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
# Cache hit rate (should be >99% for OLTP)
SELECT
sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS cache_hit_ratio
FROM pg_statio_user_tables;
Common Scenarios¶
Scenario 1: Connection Limit Exhausted¶
Application returns "FATAL: sorry, too many clients already" or "remaining connection slots are reserved for non-replication superuser connections."
Diagnosis:
-- Current connection count vs limit
SELECT count(*) AS current, max_conn AS limit
FROM pg_stat_activity, (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') s;
-- Who's holding connections
SELECT datname, usename, state, count(*) FROM pg_stat_activity
GROUP BY 1,2,3 ORDER BY 4 DESC;
-- Idle connections wasting slots
SELECT count(*) FROM pg_stat_activity WHERE state = 'idle';
SELECT count(*) FROM pg_stat_activity WHERE state = 'idle in transaction';
Fix:
# Short-term: kill idle connections
# (Do this carefully — don't kill active queries)
psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle' AND query_start < now() - interval '30 minutes';"
# Long-term: deploy PgBouncer (connection pooler) in front of PostgreSQL
# postgresql.conf: reserve some headroom
# max_connections = 200 # default 100, increase carefully
# superuser_reserved_connections = 5 # emergency access
# PgBouncer key settings (pgbouncer.ini)
# pool_mode = transaction # reuse connections per-transaction (most efficient)
# max_client_conn = 10000 # clients can connect freely
# default_pool_size = 20 # but only N backend connections per db/user
Scenario 2: Table Bloat from Dead Tuples¶
Queries on a table are slow. Table is 20 GB but only contains 2 GB of real data. Dead tuples from updates/deletes haven't been reclaimed.
Diagnosis:
-- Check dead tuple ratio
SELECT schemaname, relname, n_live_tup, n_dead_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
-- Physical size vs estimated live data size
SELECT
pg_size_pretty(pg_relation_size('mytable')) AS table_size,
n_live_tup,
pg_size_pretty(n_live_tup * 200) AS estimated_live -- rough estimate
FROM pg_stat_user_tables
WHERE relname = 'mytable';
Fix:
-- Manual VACUUM (doesn't lock table, just reclaims dead tuples)
VACUUM mytable;
-- ANALYZE to update statistics after vacuum
VACUUM ANALYZE mytable;
-- Full vacuum — reclaims space to OS, but locks the table exclusively
-- Only use during maintenance windows
VACUUM FULL mytable;
-- pg_repack (preferred over VACUUM FULL — online, no exclusive lock)
-- Install: apt install postgresql-<ver>-repack
pg_repack -d mydb -t mytable
-- Tune autovacuum for high-churn tables
ALTER TABLE mytable SET (
autovacuum_vacuum_scale_factor = 0.01, -- vacuum at 1% dead tuples (default 20%)
autovacuum_analyze_scale_factor = 0.005
);
Scenario 3: Replication Lag Spiking¶
Read replicas are serving stale data. Replication lag is climbing.
Diagnosis:
-- On primary: check replication state
SELECT client_addr, state, write_lag, flush_lag, replay_lag,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS lag_bytes
FROM pg_stat_replication;
-- On replica: check recovery status
SELECT pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(),
now() - pg_last_xact_replay_timestamp() AS replication_delay;
Common causes:
1. Replica is running a long query that blocks replay:
- Check pg_stat_activity on replica for long-running queries
- hot_standby_feedback = on can cause this (replica prevents primary vacuum)
- Fix: cancel the query on replica or set max_standby_streaming_delay
2. Network bandwidth exhausted:
- iftop / nethogs on replica machine
- Consider WAL compression: wal_compression = on in postgresql.conf
3. Replica disk I/O saturated:
- iostat -x 1 on replica
- Tune recovery_min_apply_delay or add replica capacity
4. Primary generating WAL faster than replica can apply:
- Bulk load or large migration happening
- Wait it out — lag will self-correct once load drops
Scenario 4: Identifying and Killing Long-Running Queries¶
-- Queries running > 5 minutes
SELECT pid, now() - query_start AS duration, state, wait_event_type, wait_event,
substring(query, 1, 100) AS query_snippet
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - query_start > interval '5 minutes'
ORDER BY duration DESC;
-- Terminate specific query gracefully
SELECT pg_cancel_backend(12345); -- SIGINT: cancels query, connection stays
-- If that doesn't work:
SELECT pg_terminate_backend(12345); -- SIGTERM: kills connection entirely
-- Kill all queries matching a pattern (USE CAREFULLY)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE query ILIKE '%DELETE FROM logs%'
AND state = 'active'
AND pid <> pg_backend_pid();
-- Statement timeout to prevent future runaway queries
ALTER ROLE myapp_user SET statement_timeout = '300s';
-- Or per-session:
SET statement_timeout = '300000'; -- milliseconds
Key Patterns¶
pg_dump / pg_restore¶
# Dump a single database (custom format, parallel-restorable)
pg_dump -U postgres -h localhost -Fc mydb > mydb.dump
# Dump with parallel jobs (faster for large DBs)
pg_dump -U postgres -Fd mydb -j 4 -f mydb_dir/
# Restore (custom format)
pg_restore -U postgres -h localhost -d mydb mydb.dump
# Restore with parallel jobs
pg_restore -U postgres -h localhost -d mydb -j 4 mydb_dir/
# Dump only schema (no data)
pg_dump -U postgres -s mydb > schema.sql
# Dump only specific table
pg_dump -U postgres -t mytable mydb > mytable.dump
# Dump all databases (plain SQL format, for all-in-one restore)
pg_dumpall -U postgres > all_databases.sql
# Restore all databases
psql -U postgres < all_databases.sql
# Test restore without actually restoring (list archive contents)
pg_restore --list mydb.dump | head -50
pg_hba.conf Connection Troubleshooting¶
Gotcha:
pg_hba.confis evaluated top-to-bottom, first match wins. A broadhost all all 0.0.0.0/0 rejectearly in the file will block everything below it. When debugging auth failures, always check whether a more restrictive rule above is matching before your intended rule.
# /etc/postgresql/<ver>/main/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer # unix socket as postgres OS user
host all all 127.0.0.1/32 scram-sha-256
host mydb myapp 10.0.0.0/8 scram-sha-256
hostssl all all 0.0.0.0/0 cert # require TLS + cert
# After editing pg_hba.conf, reload (no restart needed)
sudo systemctl reload postgresql
# or:
SELECT pg_reload_conf();
# Common errors:
# "Ident authentication failed" — user connecting via TCP but method=ident; change to md5/scram
# "peer authentication failed" — connecting via socket with wrong OS user
# "password authentication failed" — wrong password or method mismatch
# "no pg_hba.conf entry" — client IP/user/db combination not in any rule
postgresql.conf Key Tuning Parameters¶
Default trap:
work_memis per-sort-operation, not per-connection. A single complex query with 5 sorts and 10 hash joins can use 15xwork_mem. With 200 connections,work_mem=64MBcan theoretically consume 192 GB. Start low (16-32 MB), monitor withlog_temp_files=0, and increase selectively for specific queries withSET work_mem.
# Memory
shared_buffers = 25% of total RAM # e.g., 8GB for 32GB server
effective_cache_size = 75% of total RAM # hint to planner, not an allocation
work_mem = 64MB # per-sort/hash, can multiply by connections!
maintenance_work_mem = 512MB # for VACUUM, index builds
# WAL / Checkpoints
wal_buffers = 64MB
checkpoint_completion_target = 0.9 # spread checkpoint writes over 90% of interval
checkpoint_timeout = 15min # default 5min, increase for write-heavy loads
max_wal_size = 4GB # WAL grows to this before forcing a checkpoint
# Query planner
random_page_cost = 1.1 # default 4; lower for SSD storage
effective_io_concurrency = 200 # SSD: 200, HDD: 2
# Logging
log_slow_queries (use log_min_duration_statement):
log_min_duration_statement = 1000 # log queries taking > 1s
log_lock_waits = on
log_autovacuum_min_duration = 250ms # log autovacuum taking > 250ms
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
# Autovacuum
autovacuum_vacuum_scale_factor = 0.05 # vacuum at 5% dead tuples (default 20%)
autovacuum_analyze_scale_factor = 0.02
autovacuum_max_workers = 5 # default 3; increase for many tables
autovacuum_vacuum_cost_delay = 2ms # default 2ms; reduce for faster vacuum
pg_upgrade (Major Version Upgrade)¶
# Example: upgrade from 14 to 16
# 1. Install new PostgreSQL version alongside old
apt install postgresql-16
# 2. Stop both clusters (or just new one if freshly installed)
sudo systemctl stop postgresql@14-main
sudo systemctl stop postgresql@16-main
# 3. Run pg_upgrade as postgres user
sudo -u postgres /usr/lib/postgresql/16/bin/pg_upgrade \
-b /usr/lib/postgresql/14/bin \
-B /usr/lib/postgresql/16/bin \
-d /var/lib/postgresql/14/main \
-D /var/lib/postgresql/16/main \
-o '-c config_file=/etc/postgresql/14/main/postgresql.conf' \
-O '-c config_file=/etc/postgresql/16/main/postgresql.conf'
# 4. Check upgrade (--check flag, no actual changes)
# Add --check to the above command first
# 5. Start new cluster
sudo systemctl start postgresql@16-main
# 6. Run analyze on new cluster (statistics not copied)
sudo -u postgres vacuumdb --all --analyze-in-stages
# 7. Remove old cluster after verifying
./delete_old_cluster.sh # generated by pg_upgrade
Extension Management¶
-- List installed extensions
SELECT name, default_version, installed_version FROM pg_available_extensions
WHERE installed_version IS NOT NULL;
-- Install extension (must be in shared_preload_libraries if it needs it)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS uuid-ossp;
-- pg_stat_statements — find slow queries
-- Requires: shared_preload_libraries = 'pg_stat_statements' in postgresql.conf + restart
SELECT query, calls, total_exec_time/calls AS avg_ms, rows/calls AS avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- Reset statistics
SELECT pg_stat_statements_reset();