| name | db-performance |
| type | skill |
| description | PostgreSQL query performance — EXPLAIN ANALYZE, index design, pg_stat_statements, slow query detection, connection pool tuning. |
| related-rules | ["access-control.md","migration-runbook.md"] |
| allowed-tools | Read, Bash |
Skill: Database Performance
Expertise: EXPLAIN ANALYZE, index design (partial/covering), pg_stat_statements, autovacuum tuning, PgBouncer sizing.
When to load
When investigating slow queries, designing indexes, tuning PostgreSQL config, or sizing PgBouncer pools.
Query Analysis with pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
left(query, 120) AS query_snippet,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(stddev_exec_time::numeric, 2) AS stddev_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
SELECT
left(query, 120),
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(rows::numeric / calls, 1) AS rows_per_call
FROM pg_stat_statements
WHERE calls > 100
ORDER BY mean_exec_time DESC
LIMIT 10;
SELECT
left(query, 120),
calls,
round(mean_exec_time::numeric, 1) AS mean_ms,
shared_blks_read + shared_blks_hit AS total_blocks,
round(shared_blks_hit::numeric / NULLIF(shared_blks_hit + shared_blks_read, 0) * 100, 1) AS cache_hit_pct
FROM pg_stat_statements
WHERE calls > 50
ORDER BY shared_blks_read DESC
LIMIT 10;
SELECT pg_stat_statements_reset();
EXPLAIN ANALYZE (reading execution plans)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.*, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
AND o.created_at > now() - interval '7 days';
Index Design Patterns
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders(status, created_at)
WHERE status IN ('pending', 'processing');
CREATE INDEX CONCURRENTLY idx_orders_customer_covering
ON orders(customer_id, created_at)
INCLUDE (status, total_amount);
CREATE INDEX CONCURRENTLY idx_users_email_lower
ON users(lower(email));
CREATE INDEX CONCURRENTLY idx_events_data_type
ON events USING GIN (data jsonb_path_ops);
Identifying Missing Indexes
SELECT
schemaname || '.' || relname AS table,
seq_scan,
seq_tup_read,
idx_scan,
round(seq_scan::numeric / NULLIF(seq_scan + idx_scan, 0) * 100, 1) AS seq_pct
FROM pg_stat_user_tables
WHERE seq_scan > 1000
AND seq_tup_read > 10000
ORDER BY seq_tup_read DESC
LIMIT 20;
SELECT
schemaname || '.' || tablename AS table,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan AS scans
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisunique
AND indpred IS NULL
ORDER BY pg_relation_size(indexrelid) DESC;
PostgreSQL Configuration Tuning
ALTER SYSTEM SET shared_buffers = '4GB';
ALTER SYSTEM SET effective_cache_size = '12GB';
ALTER SYSTEM SET work_mem = '64MB';
ALTER SYSTEM SET maintenance_work_mem = '1GB';
ALTER SYSTEM SET max_worker_processes = 8;
ALTER SYSTEM SET max_parallel_workers = 4;
ALTER SYSTEM SET max_parallel_workers_per_gather = 2;
ALTER SYSTEM SET wal_buffers = '64MB';
ALTER SYSTEM SET checkpoint_completion_target = 0.9;
ALTER SYSTEM SET min_wal_size = '1GB';
ALTER SYSTEM SET max_wal_size = '4GB';
SELECT pg_reload_conf();
PgBouncer Pool Sizing
Formula:
max_server_connections = min(max_connections_pg - 5, available_connections)
default_pool_size = max_server_connections / number_of_services
max_client_conn = default_pool_size × 10 (clients can queue)
Example (max_connections=200, 5 services):
default_pool_size = (200 - 5) / 5 = 39 → set to 40
max_client_conn = 400
reserve_pool_size = 5 (emergency burst)
[pgbouncer]
pool_mode = transaction
max_client_conn = 500
default_pool_size = 40
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 600
client_idle_timeout = 0
query_wait_timeout = 30
Autovacuum Tuning for High-Write Tables
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 2
);