MeshWorld India Logo MeshWorld.
PostgreSQL Database Performance Backend SQL 7 min read

PostgreSQL Performance Tuning: Query Plans & Connection Pooling

Rachel
By Rachel
PostgreSQL Performance Tuning: Query Plans & Connection Pooling

Most PostgreSQL performance problems have the same handful of root causes. Sequential scans on large tables. N+1 query patterns from ORMs. Connection pool exhaustion under load. Autovacuum falling behind on high-write tables. Once you know how to read query plans and where to look, you can diagnose most slowdowns in under an hour.

TL;DR
  • EXPLAIN ANALYZE is your primary diagnostic tool — look for Seq Scans with high row counts and loop joins at scale
  • The most common causes of slow queries: missing indexes, N+1 patterns, poor join order on large tables
  • pg_stat_statements extension identifies the slowest queries across your entire database
  • PgBouncer in transaction-mode pooling reduces connection overhead by 80–90% under high concurrency
  • Autovacuum defaults work for most tables, but high-write tables need tuned thresholds

Prerequisites

  • PostgreSQL 14+ (most features here apply to pg 12+ as well)
  • Superuser or pg_monitor role for system views
  • pg_stat_statements extension available (ships with PostgreSQL, just needs enabling)

Finding the Slow Queries

Before diving into individual queries, find the worst offenders across the whole database.

Enable pg_stat_statements in postgresql.conf:

plaintext
shared_preload_libraries = 'pg_stat_statements'

Reload PostgreSQL, then create the extension:

sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Now query the slowest queries by total time:

sql
SELECT
  round(total_exec_time::numeric, 2) AS total_ms,
  calls,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

total_exec_time shows cumulative time — a query that runs a million times and takes 0.1ms each contributes 100,000ms total. mean_exec_time shows per-call cost. Both matter: high total means it’s consuming database capacity; high mean means individual calls are slow.

Reset stats after tuning to measure improvements from a clean baseline:

sql
SELECT pg_stat_statements_reset();

Reading EXPLAIN ANALYZE

EXPLAIN shows what PostgreSQL plans to do. EXPLAIN ANALYZE runs the query and shows what it actually did.

sql
EXPLAIN ANALYZE
SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 50;

What to look for:

Seq Scan on a large table — PostgreSQL read every row. Unless the table is tiny or the query returns most rows anyway, this usually means a missing index.

plaintext
Seq Scan on orders  (cost=0.00..18340.00 rows=240 width=120)
                    (actual time=0.03..412.50 rows=240 loops=1)
  Filter: ((status)::text = 'pending'::text)
  Rows Removed by Filter: 847620

847,620 rows scanned to return 240. Add a partial index: CREATE INDEX ON orders(created_at DESC) WHERE status = 'pending'.

Hash Join vs Loop Join — Hash Join is generally good for larger data sets. Loop Join with an inner Seq Scan is the dangerous combination:

plaintext
Loop Join  (cost=0.00..98234.00 rows=240 width=240)
  ->  Seq Scan on orders  (actual rows=240 loops=1)
  ->  Seq Scan on users  (actual rows=1 loops=240)

loops=240 on the inner Seq Scan means PostgreSQL scanned the entire users table 240 times. If you see loops=N on an inner node with N > 10, that node is your bottleneck.

Actual vs estimated rows — the rows=X in the cost estimate vs rows=Y in the actual. Large discrepancies mean stale statistics. Fix with ANALYZE tablename.

Buffers — add BUFFERS to see memory vs disk reads:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...

High Buffers: read=N means data from disk. High Buffers: hit=N means from shared buffer cache. If you’re hitting disk heavily for a frequently-accessed table, consider increasing shared_buffers.


Common Slow Query Patterns

1. Missing index on a filtered or joined column. The fix is usually CREATE INDEX. Composite indexes when filtering on multiple columns — put equality columns before range columns.

2. N+1 queries from ORMs. This is the most common performance problem in apps using Sequelize, SQLAlchemy, ActiveRecord, or Prisma. Loading 100 orders triggers 101 queries:

sql
-- Query 1: get 100 orders
SELECT * FROM orders LIMIT 100;

-- Queries 2–101: get user for each order (generated by ORM)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
-- ...×100

The fix is eager loading: JOIN in SQL, include in Prisma, joinedload in SQLAlchemy. One query instead of 101. Detect N+1 in pg_stat_statements: look for queries with extremely high calls counts and low mean_exec_time.

3. SELECT * in performance-sensitive paths. Name only the columns you use. Partial data is faster to transfer and easier for PostgreSQL to cache.

4. Missing LIMIT on potentially large result sets. A query that returns 10,000 rows when the UI shows 20 wastes everything. Use LIMIT + OFFSET or, better, keyset pagination for large datasets.


Connection Pooling with PgBouncer

PostgreSQL creates an OS process for each connection. Under high concurrency, the overhead of process creation and context switching becomes significant. PgBouncer maintains a smaller pool of actual database connections and queues application requests.

bash
sudo apt install pgbouncer -y

Minimal /etc/pgbouncer/pgbouncer.ini:

ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
server_idle_timeout = 600

pool_mode = transaction — the connection is returned to the pool after each transaction, not each session. This is the most efficient mode. Caveat: you can’t use session-level features (advisory locks, SET commands that persist across transactions, some prepared statement patterns) with transaction mode.

Your application now connects to PgBouncer on port 6432 instead of PostgreSQL directly on 5432. PgBouncer multiplexes up to 1,000 client connections through 20 actual database connections.


Autovacuum Settings That Matter

PostgreSQL’s MVCC model keeps dead row versions after updates and deletes until autovacuum reclaims them. Default settings work for typical workloads but fall behind on high-write tables.

Check autovacuum activity:

sql
SELECT schemaname, tablename,
  n_dead_tup,
  n_live_tup,
  round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

Dead percentage above 10–20% on a frequently-queried table means autovacuum is behind. Symptoms: bloated tables, stale statistics, growing pg_relation_size.

Tune autovacuum for a specific high-write table:

sql
ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_analyze_scale_factor = 0.005,
  autovacuum_vacuum_cost_delay = 2
);

For large tables (millions of rows), the default autovacuum_vacuum_scale_factor = 0.2 means autovacuum doesn’t trigger until 20% of rows are dead. On a 1M-row table, that’s 200,000 dead rows before vacuum kicks in. Setting scale_factor = 0.01 makes it trigger at 10,000 dead rows instead.


FAQ

How do I know if my server is CPU-bound or I/O-bound? Check EXPLAIN (ANALYZE, BUFFERS). High disk reads means I/O-bound — increase shared_buffers (typically 25% of RAM) or add faster storage. High execution time with cache hits means CPU-bound — look at parallelism or query optimization. On the OS level, iostat -x 1 and top show whether the bottleneck is disk or CPU.

When should I use EXPLAIN vs EXPLAIN ANALYZE? Use EXPLAIN for non-destructive planning. Use EXPLAIN ANALYZE for all performance investigations — you need actual row counts and timing, not estimates.

Is it safe to run ANALYZE on a production table? Yes. ANALYZE takes only a brief share lock, doesn’t block reads or writes, and completes quickly (it samples, not a full scan). VACUUM is also safe in its default form. VACUUM FULL takes an exclusive lock — avoid it on production during business hours.


For more foundation, the Database Indexing Explained covers B-trees and composite indexes in detail.