Nurture TechnologiesNurture Tech
DevOps16 min read·July 24, 2026

PostgreSQL Performance Degraded After Growth

PostgreSQLPgBouncerpganalyzeDatadogAWS RDS

PostgreSQL performance problems at scale follow a predictable pattern: missing indexes on filter columns, table bloat from deleted rows that autovacuum cannot keep up with, stale statistics causing the query planner to choose sequential scans, and connection pool exhaustion. Each is diagnosable with built-in tools.

Problem Summary

PostgreSQL is exceptionally capable under load, but it requires proactive maintenance and correct configuration to stay fast as data volumes grow. The default settings are optimised for a server with 256MB of RAM running in the year 2005 they are not sensible defaults for a production database handling millions of rows. Performance degradation after growth almost always involves some combination of: sequential scans on large tables due to missing indexes, physical table bloat from accumulated dead tuples that autovacuum has not cleaned, query planner statistics that are stale and cause incorrect execution plans, and connection pool exhaustion when application instances multiply. Each of these is diagnosable using PostgreSQL's built-in statistics views and the EXPLAIN ANALYZE command.

Symptoms

  • Queries that previously completed in 50ms now take 2-5 seconds with the same parameters
  • pg_stat_activity shows many queries in 'idle in transaction' or long-running states
  • Sequential scans (Seq Scan) appearing in EXPLAIN ANALYZE output on tables with millions of rows
  • PostgreSQL CPU above 80% during normal business hours
  • Connection refused errors in application logs: 'too many connections'
  • Table sizes growing faster than expected relative to row count, indicating bloat
  • autovacuum_count barely incrementing despite high write/delete volume
  • Query plans changing unexpectedly after data volume crosses a threshold

Business Impact

PostgreSQL performance degradation compounds quickly. Slow queries hold database connections open longer, which exhausts the connection pool, which causes application timeouts, which causes user-facing errors. A slow query that holds a lock on a high-write table can block all writes to that table, causing application-level failures across multiple features simultaneously. At scale, a single missing index on a hot table can be the direct cause of a site-wide outage.

Root Cause

PostgreSQL performance problems at growth have well-known root causes: missing indexes on columns used in WHERE, JOIN ON, and ORDER BY clauses on high-volume tables; physical bloat from rows deleted or updated without sufficient vacuum cycles; stale planner statistics causing the query planner to underestimate row counts and choose sequential scans over index scans; connection pool exhaustion from too many application instances sharing too few database connections; and queries running on the primary that should be routed to a read replica.

Never run VACUUM FULL in production during business hours. VACUUM FULL acquires an exclusive lock on the table and blocks all reads and writes for the duration of the operation. Use regular VACUUM ANALYZE instead, and schedule VACUUM FULL during a maintenance window if needed.

Diagnosis

Step 1: Enable pg_stat_statements and Find Slow Queries

pg_stat_statements is a PostgreSQL extension that tracks execution statistics for all SQL statements. It is the single most useful tool for diagnosing query-level performance problems. If it is not enabled, enable it now on AWS RDS it is available as a shared_preload_library.

enable_pg_stat_statements.sql
-- Enable pg_stat_statements (requires restart if adding to shared_preload_libraries)
-- Add to postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
-- Then in psql:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Find the top 20 slowest queries by mean execution time
SELECT
  LEFT(query, 120) AS query_snippet,
  calls,
  ROUND(mean_exec_time::numeric, 2) AS avg_ms,
  ROUND(total_exec_time::numeric / 1000, 1) AS total_seconds,
  ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
  ROUND(rows::numeric / calls, 1) AS avg_rows_returned
FROM pg_stat_statements
WHERE calls > 5
  AND mean_exec_time > 50
ORDER BY mean_exec_time DESC
LIMIT 20;

-- Reset stats to start fresh after making changes
SELECT pg_stat_statements_reset();

Step 2: Run EXPLAIN ANALYZE on Slow Queries

For each slow query identified in pg_stat_statements, run EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) with representative parameters. The output tells you exactly what the query planner is doing: which indexes it is using (or not using), how many rows it estimated versus actually retrieved, and where the time is being spent. A large discrepancy between estimated rows and actual rows is a sign of stale statistics.

explain_analyze_examples.sql
-- Basic EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.*, u.email, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
  AND o.created_at >= NOW() - INTERVAL '7 days'
ORDER BY o.created_at DESC;

-- What to look for in output:
-- "Seq Scan" on large tables = missing index
-- "Rows Removed by Filter: 50000" = index exists but is not selective enough
-- estimated rows=5 vs actual rows=48291 = stale statistics, run ANALYZE
-- "Buffers: shared hit=0 read=50000" = data not cached, consider work_mem increase
-- cost=0.00..99999.00 on nested loop = N+1 at SQL level

-- Update statistics for a specific table
ANALYZE orders;

-- Update statistics for the entire database
ANALYZE VERBOSE;

Step 3: Find and Create Missing Indexes

PostgreSQL tracks index usage statistics. Any index with zero scans is potentially unnecessary (wasting write overhead). Any column that appears frequently in query WHERE clauses but has no index is a candidate for creation. Creating indexes concurrently avoids locking the table.

index_analysis.sql
-- Find unused indexes (candidates for removal)
SELECT
  schemaname,
  tablename,
  indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexname NOT LIKE '%pkey'  -- keep primary keys
ORDER BY pg_relation_size(indexrelid) DESC;

-- Find tables with high sequential scan rates (candidates for new indexes)
SELECT
  tablename,
  seq_scan,
  idx_scan,
  n_live_tup,
  pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
  AND n_live_tup > 50000
ORDER BY seq_scan DESC;

-- Create an index WITHOUT locking the table (CONCURRENT)
CREATE INDEX CONCURRENTLY idx_orders_user_id_status
  ON orders (user_id, status)
  WHERE status IN ('pending', 'processing');  -- partial index for common filter

-- Create a composite index for a common query pattern
CREATE INDEX CONCURRENTLY idx_orders_created_at_status
  ON orders (created_at DESC, status)
  INCLUDE (id, user_id, total_amount);  -- covering index avoids table heap fetch

Step 4: Check and Fix Table Bloat

PostgreSQL uses MVCC (Multi-Version Concurrency Control), which means deleted and updated rows are not immediately removed from storage. The VACUUM process cleans these dead tuples. If autovacuum is not keeping up with your write volume, dead tuples accumulate and table bloat occurs: tables consume far more disk space than the live rows justify, and sequential scans read more pages than necessary.

table_bloat_check.sql
-- Check table bloat and dead tuple accumulation
SELECT
  schemaname,
  tablename,
  n_live_tup,
  n_dead_tup,
  ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze,
  pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;

-- Manually vacuum and analyze a bloated table
VACUUM (VERBOSE, ANALYZE) orders;

-- Check autovacuum configuration for a specific table
SELECT relname, reloptions
FROM pg_class
WHERE relname = 'orders';

-- Tune autovacuum for a high-write table (run per-table)
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.01,   -- vacuum when 1% of rows are dead (default 0.2)
  autovacuum_analyze_scale_factor = 0.005, -- analyze when 0.5% of rows change
  autovacuum_vacuum_cost_delay = 2         -- reduce IO throttling for this table
);

Step 5: Fix Connection Pool Exhaustion with PgBouncer

PostgreSQL creates a new OS process per connection. At 200+ concurrent connections, connection overhead alone consumes significant memory. PgBouncer is a lightweight connection pooler that sits between your application and PostgreSQL, multiplexing many application connections into a smaller number of actual database connections. Transaction-mode pooling is appropriate for most web applications.

pgbouncer.ini
# /etc/pgbouncer/pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp

[pgbouncer]
listen_port = 6432
listen_addr = 127.0.0.1

# Transaction-mode pooling: connection returned to pool after each transaction
# This allows many app connections to share few DB connections
pool_mode = transaction

# Maximum database connections PgBouncer will open to PostgreSQL
max_client_conn = 1000        # clients can connect up to this limit
default_pool_size = 25        # actual PostgreSQL connections per db/user pair
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3

# Authentication
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# Logging
logfile = /var/log/pgbouncer/pgbouncer.log
pidfile = /var/run/pgbouncer/pgbouncer.pid

# Timeouts
server_idle_timeout = 600
client_idle_timeout = 0
query_timeout = 0

Check current connection count and pool utilisation: connect to PgBouncer's admin database (psql -p 6432 -U pgbouncer pgbouncer) and run SHOW POOLS; and SHOW STATS;. If cl_waiting is consistently above 0, increase default_pool_size or scale the PostgreSQL instance.

Production Failure Scenarios

Scenario 1: Autovacuum Blocked by Long-Running Transaction

A reporting query runs inside a transaction and takes 2 hours to complete. During this time, autovacuum cannot clean any tables that have rows modified after the transaction started (due to MVCC snapshot requirements). Dead tuples accumulate across all tables. Table bloat causes sequential scans to read far more pages than necessary. Queries that took 50ms now take 5 seconds. Fix: set statement_timeout and idle_in_transaction_session_timeout in postgresql.conf to prevent runaway transactions from blocking autovacuum. Route long-running reports to a read replica.

long_transaction_check.sql
-- Find long-running transactions blocking autovacuum
SELECT
  pid,
  now() - pg_stat_activity.xact_start AS duration,
  query,
  state,
  wait_event_type,
  wait_event
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > INTERVAL '5 minutes'
ORDER BY xact_start;

-- Set session-level timeouts (add to postgresql.conf for global effect)
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '60s';

-- Terminate a blocking query (use with caution)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE now() - xact_start > INTERVAL '1 hour'
  AND state = 'idle in transaction';

Scenario 2: Query Planner Chooses Sequential Scan Despite Index

An index exists on orders.status but PostgreSQL performs a sequential scan anyway. This happens when statistics are stale and the planner estimates only 100 rows match status='pending' when the actual count is 500,000 at that selectivity, the planner correctly chooses a sequential scan. Running ANALYZE updates the statistics and the planner switches to an index scan. Fix: run ANALYZE on the table, or tune autovacuum_analyze_scale_factor to trigger more frequent statistics updates.

Scenario 3: Index on Wrong Column Order in Composite Index

A composite index exists as (status, created_at) but the most common query pattern filters by created_at first and status second. PostgreSQL can only use the leftmost prefix of a composite index efficiently. The index is effectively unused for this query pattern. Fix: create a new index with the correct column order (created_at, status) and drop the old one after confirming the new index is being used.

Scenario 4: write_ahead_log Settings Causing I/O Saturation

Default PostgreSQL checkpoint settings cause checkpoint writes to burst every 5 minutes, saturating disk I/O and causing query latency spikes every 5 minutes. Fix: increase checkpoint_completion_target to 0.9 (spread writes over 90% of the checkpoint interval), increase max_wal_size to reduce checkpoint frequency, and use SSD storage for the WAL directory.

Scenario 5: Foreign Keys Without Indexes Causing Lock Contention

A foreign key constraint exists on orders.user_id referencing users.id, but there is no index on orders.user_id. When a user record is deleted or updated, PostgreSQL must scan the entire orders table to check for referencing rows. This full-table scan acquires a share lock that blocks concurrent writes. Fix: add an index on every foreign key column in every table.

Prevention Checklist

  • Enable pg_stat_statements at cluster creation and review top queries by mean_exec_time weekly
  • Add indexes on all foreign key columns at migration creation time never create a foreign key without an index
  • Set autovacuum_vacuum_scale_factor = 0.01 and autovacuum_analyze_scale_factor = 0.005 on high-write tables
  • Set statement_timeout = '30s' and idle_in_transaction_session_timeout = '60s' in postgresql.conf
  • Deploy PgBouncer in transaction mode between application and PostgreSQL when running more than 3 application instances
  • Run EXPLAIN (ANALYZE, BUFFERS) on every new query before deploying to production
  • Set checkpoint_completion_target = 0.9 and max_wal_size = 2GB to smooth checkpoint I/O
  • Monitor table bloat weekly and alert when dead_pct exceeds 20% for tables over 1GB
  • Use CREATE INDEX CONCURRENTLY for all index creation to avoid table locks
  • Route reporting and analytics queries to a read replica never run them on the primary

Resolution Summary

Start with pg_stat_statements to find the slowest queries by mean execution time. Run EXPLAIN (ANALYZE, BUFFERS) on each. For queries with sequential scans on large tables, create the appropriate index concurrently. For queries with plan estimation errors, run ANALYZE on the affected tables. For connection exhaustion, deploy PgBouncer. For table bloat, tune per-table autovacuum settings and run VACUUM ANALYZE manually on the worst offenders. These five steps resolve the majority of PostgreSQL performance problems in production.

Lessons Learned

  • pg_stat_statements is the single most useful PostgreSQL performance tool enable it from day one
  • EXPLAIN ANALYZE with the BUFFERS option reveals cache efficiency, not just query plan structure
  • Every foreign key must have a corresponding index this is not optional at production scale
  • Autovacuum default settings are tuned for small databases; production tables need explicit per-table configuration
  • Long-running transactions block autovacuum silently set idle_in_transaction_session_timeout to prevent this
  • Stale query planner statistics are the most common cause of unexpected sequential scans on indexed columns

Conclusion

PostgreSQL performance degradation at scale is almost always diagnosable using the tools PostgreSQL ships with. The sequence is: find slow queries with pg_stat_statements, understand execution plans with EXPLAIN ANALYZE, fix the root cause (missing index, stale statistics, table bloat, or connection exhaustion), and verify the fix with a second EXPLAIN ANALYZE. Pair this with correct autovacuum tuning, per-table vacuum settings for high-write tables, and PgBouncer for connection multiplexing, and PostgreSQL will serve you well into hundreds of millions of rows without a rewrite.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

How do I check if pg_stat_statements is enabled?+

Run: SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements'; If it returns no rows, the extension is not installed. On RDS, add pg_stat_statements to the shared_preload_libraries parameter in the parameter group and reboot. On self-managed PostgreSQL, add it to postgresql.conf and restart, then run CREATE EXTENSION pg_stat_statements; in the target database.

What is the difference between VACUUM and VACUUM FULL?+

Regular VACUUM reclaims dead tuple space for reuse by the same table but does not return space to the operating system. It runs concurrently with other operations. VACUUM FULL rewrites the entire table, returns space to the OS, and removes bloat completely but it holds an exclusive lock blocking all reads and writes for the duration. Use regular VACUUM for routine maintenance and reserve VACUUM FULL for scheduled maintenance windows on severely bloated tables.

How do I know if my connection pool is exhausted?+

Check pg_stat_activity: SELECT count(*), state FROM pg_stat_activity GROUP BY state; If the total approaches your max_connections setting (default 100), you are at risk. In PgBouncer, run SHOW POOLS; from the admin database if cl_waiting is greater than 0, clients are waiting for connections. Add monitoring alerts when active connections exceed 80% of max_connections.

What is a covering index and when should I use it?+

A covering index (created with INCLUDE clause in PostgreSQL 11+) stores additional columns in the index leaf pages alongside the index key columns. This allows the query planner to satisfy the query entirely from the index without fetching the table heap row, which is called an 'Index Only Scan'. Use covering indexes when a query selects a small number of columns (id, status, created_at) from a large table and those columns can all be included in the index.

Should I use partial indexes?+

Yes, for columns with highly skewed value distributions. If 95% of orders have status='completed' and you only ever query for status IN ('pending', 'processing'), a partial index WHERE status IN ('pending', 'processing') is much smaller than a full index on status, is faster to scan, and fits better in cache. Partial indexes are underused and often provide dramatic improvements over full-column indexes on filtered queries.

What PgBouncer pool mode should I use?+

Transaction mode is appropriate for most web applications: the database connection is returned to the pool after each transaction completes, allowing many application connections to share few database connections. Session mode returns the connection only when the application disconnects, providing less multiplexing. Statement mode is only for simple single-statement workloads. Do not use transaction mode if your application uses advisory locks, LISTEN/NOTIFY, or prepared statements without reset.

How often should autovacuum run on a high-write table?+

For tables with high delete or update rates, autovacuum should trigger when approximately 1-5% of rows are dead tuples. Set autovacuum_vacuum_scale_factor = 0.01 per table to trigger at 1%. The default of 0.2 (20%) is far too infrequent for tables receiving thousands of updates per minute. Monitor n_dead_tup in pg_stat_user_tables and ensure last_autovacuum is recent for all high-write tables.

What is work_mem and how do I tune it?+

work_mem is the amount of memory PostgreSQL uses per sort or hash operation within a query. The default of 4MB means large sorts and hash joins spill to disk, causing significant slowdowns. Set it to 32-256MB for interactive queries that involve sorting. Be careful: work_mem applies per sort operation, not per query a query with 5 sort operations uses 5x work_mem. Use SET LOCAL work_mem = '128MB'; in sessions or for specific queries rather than setting it globally.

How do I identify queries that are blocking other queries?+

Run: SELECT blocking.pid, blocking.query, blocked.pid AS blocked_pid, blocked.query AS blocked_query FROM pg_stat_activity blocking JOIN pg_stat_activity blocked ON blocking.pid = ANY(pg_blocking_pids(blocked.pid)); This shows which queries are blocking others. The blocking query may need to be terminated (pg_terminate_backend) or its transaction shortened to release locks promptly.

Should I use an RDS read replica or a separate analytics database?+

An RDS read replica is appropriate for reporting queries that are close in data freshness requirements (replication lag is typically under 1 second) and use the same schema as the primary. A separate analytics database (Redshift, BigQuery, ClickHouse) is appropriate for complex aggregation queries over large historical datasets, queries that require different schema optimisation (columnar storage), or workloads where replication lag is not acceptable. For most SaaS applications under 500GB, an RDS read replica is sufficient.