Performance, VACUUM, and Monitoring

Introduction

Slow queries and bloated tables hurt every app. This chapter goes deeper on EXPLAIN ANALYZE, slow query logging, the pg_stat_statements extension, ANALYZE statistics, VACUUM and autovacuum, connection pooling, and essential postgresql.conf knobs. Compare with MySQL query performance—PostgreSQL requires vacuum understanding.

Prerequisites

EXPLAIN ANALYZE in Practice

Find slow user lookup:

sql
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT id, email FROM users WHERE email = 'ada@example.com';

Before index—often Seq Scan:

text
Seq Scan on users ...
  Filter: (email = 'ada@example.com'::text)
  Rows Removed by Filter: ...

Add index and re-run:

sql
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
 
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM users WHERE email = 'ada@example.com';

Look for Index Scan or Index Only Scan and lower actual time.

Join plan:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.title, u.display_name
FROM posts p
INNER JOIN users u ON u.id = p.user_id
WHERE p.published_at >= '2026-01-01';

Watch for:

NodeConcern
Seq Scan on large tableMissing index or low selectivity
Nested Loop with huge outerMissing index on inner join key
Hash JoinLarge memory—work_mem
Sort with high costORDER BY without index

Code explanation:

  • estimated rows far from actual rows → run ANALYZE
  • BUFFERS shows cache hits vs disk reads

Slow Query Logging

In postgresql.conf (or Docker ALTER SYSTEM):

conf
log_min_duration_statement = 500
log_line_prefix = '%m [%p] %u@%d '

Log statements slower than 500 ms. Reload:

sql
SELECT pg_reload_conf();

Session-only (dev):

sql
SET log_min_duration_statement = 0;

Find log location:

sql
SHOW log_directory;
SHOW data_directory;

Docker:

bash
docker logs postgres 2>&1 | tail -50

pg_stat_statements (Concept)

Extension aggregating query stats—popular in production:

sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
 
SELECT
  calls,
  round(total_exec_time::numeric, 2) AS total_ms,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Requires shared_preload_libraries = 'pg_stat_statements' and restart on some installs—check docs for your version.

Reset stats after deploy:

sql
SELECT pg_stat_statements_reset();

ANALYZE Statistics

Planner uses row count and value distribution estimates:

sql
ANALYZE posts;
ANALYZE users;

Check last analyze:

sql
SELECT relname, last_analyze, last_autoanalyze, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

Autovacuum runs ANALYZE automatically when enough rows change—manual ANALYZE after large bulk loads.

VACUUM and Dead Tuple Bloat

UPDATE/DELETE leave dead row versions (MVCC). VACUUM marks space reusable:

sql
VACUUM posts;
VACUUM ANALYZE posts;

Verbose bloat check (concept):

sql
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC;

Code explanation:

  • High n_dead_tup → table bigger than needed, scans slower
  • Long open transactions block vacuum—Isolation and Locking

VACUUM FULL rewrites entire table—exclusive lock, downtime—last resort:

sql
-- VACUUM FULL posts;  -- blocks writes; use pg_repack or maintenance window instead

Autovacuum

Background worker reclaims dead tuples and updates stats:

sql
SHOW autovacuum;
SHOW autovacuum_vacuum_threshold;

Tune per table if needed (advanced):

sql
ALTER TABLE posts SET (
  autovacuum_vacuum_scale_factor = 0.05
);

Tip

Postgres vs MySQL

MySQL InnoDB purge is largely automatic and invisible. PostgreSQL autovacuum is equally critical—monitor n_dead_tup and long transactions.

Optimization Checklist

HabitWhy
Avoid SELECT *Less I/O
Index WHERE and JOIN keysFewer seq scans
Match composite index prefixIndexes chapter
Use TIMESTAMPTZ ranges, not date(col)Index-friendly predicates
Batch writes in transactionsFewer WAL syncs
EXPLAIN ANALYZE on stagingMeasure, do not guess

Bad pattern:

sql
SELECT * FROM posts WHERE date_part('year', published_at) = 2026;

Better:

sql
SELECT id, title FROM posts
WHERE published_at >= '2026-01-01' AND published_at < '2027-01-01';

Connection and Memory Settings (Concept)

SettingRole
max_connectionsCap sessions—each uses RAM
shared_buffersPostgreSQL page cache—often ~25% RAM on dedicated DB (guideline)
work_memPer sort/hash operation—raise carefully (multiplies by concurrent ops)
effective_cache_sizeHint to planner about OS cache—does not allocate RAM
maintenance_work_memVACUUM, CREATE INDEX speed

Check:

sql
SHOW max_connections;
SHOW shared_buffers;
SHOW work_mem;

Too many connections error → use PgBouncer (pooler) in front of Postgres—common in production; apps open a pool, PgBouncer multiplexes to fewer DB connections.

Monitoring Views

Active queries:

sql
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;

Database size:

sql
SELECT pg_size_pretty(pg_database_size(current_database()));

Table sizes:

sql
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

Cache hit ratio (rough health):

sql
SELECT
  sum(heap_blks_hit) / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS heap_hit_ratio
FROM pg_statio_user_tables;

Low ratio on hot tables → consider more RAM or better indexes.

Bottleneck Workflow

text
1. User reports slow page
2. Find SQL in app logs or pg_stat_statements
3. EXPLAIN ANALYZE on staging copy
4. Add/fix index or rewrite query; ANALYZE
5. Check dead tuples and autovacuum if bloat suspected
6. Re-measure; confirm slow log quiet

FAQ

Seq Scan on small table—bad?

No—optimizer correctly reads whole table when cheaper than index.

Index did not help?

Wrong column order, stale stats, or function on column without expression index.

How often to VACUUM manually?

Rarely if autovacuum healthy—after massive DELETE/UPDATE, run VACUUM ANALYZE.

VACUUM FULL vs REINDEX?

VACUUM FULL compacts heap; REINDEX rebuilds indexes—different bloat sources.

shared_buffers on laptop?

Defaults are modest—Docker dev rarely needs tuning; focus on indexes and query shape first.

Redis instead of tuning Postgres?

Redis caches hot reads—Postgres remains source of truth; fix slow SQL before caching bad queries.