PostgreSQL has become the default choice for production relational databases. Stack Overflow's 2026 developer survey shows PostgreSQL as the most-used database for the fourth consecutive year, surpassing MySQL. Its ACID compliance, JSON/JSONB hybrid storage, rich extension ecosystem (PostGIS, pg_vector, TimescaleDB), and zero license cost make it the pragmatic choice for most production systems.
But default configuration does not mean optimal configuration. As data volume grows and concurrent user count increases, PostgreSQL's default settings hit their limits. Query plans degrade. Connection overhead accumulates. VACUUM runs compete with production queries. This guide covers PostgreSQL performance optimization from query-level analysis through infrastructure-level scaling.
PostgreSQL Performance: Query Analysis with EXPLAIN ANALYZE
Performance optimization begins with identifying slow queries, not guessing. PostgreSQL provides two tools for this.
Identifying Slow Queries with pg_stat_statements
pg_stat_statements collects statistics on every query executed, including total time, call count, and rows returned. Enable it with:
-- postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
Query the slowest queries:
SELECT
query,
calls,
total_exec_time / 1000 AS total_seconds,
mean_exec_time AS avg_ms,
rows / calls AS avg_rows,
stddev_exec_time AS stddev_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Focus on queries with high total_exec_time (aggregate impact on the system) rather than just high mean_exec_time (individual query speed). A query averaging 500ms but called once per day is less critical than a query averaging 5ms but called 1 million times per day.
Analyzing Query Plans with EXPLAIN ANALYZE
EXPLAIN ANALYZE executes the query and shows the actual execution plan with real timings:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.email, o.order_date, SUM(li.quantity * li.unit_price) as order_total
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN line_items li ON o.id = li.order_id
WHERE o.status = 'completed'
AND o.order_date >= NOW() - INTERVAL '30 days'
GROUP BY u.email, o.order_date
ORDER BY order_total DESC;
What to look for in the plan:
Seq Scanon large tables — indicates a missing or unused indexHash JoinvsNested Loop— Hash Join on large tables, Nested Loop when one side is smallRows=X(estimated) vsactual rows=Y— large discrepancy indicates stale statisticsBuffers: shared hit=X read=Y— highreadvalues indicate frequent disk I/O
Force statistics update when estimates are stale:
ANALYZE table_name;
-- Or full analysis for all tables:
ANALYZE VERBOSE;
Indexing Strategy for PostgreSQL Performance
Indexes are the primary mechanism for query optimization. The wrong indexes hurt as much as missing indexes — they slow down writes and VACUUM without improving read performance.
B-Tree Index (Default)
Standard index for equality and range queries:
-- Single column
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- Compound index (ESR rule: Equality → Sort → Range)
-- Supports: WHERE status = 'completed' ORDER BY order_date DESC
CREATE INDEX idx_orders_status_date ON orders (status, order_date DESC);
-- Partial index — only index subset of rows
CREATE INDEX idx_orders_active ON orders (user_id, order_date)
WHERE status IN ('pending', 'processing');
Expression and Covering Indexes
-- Expression index for case-insensitive search
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- Usage: WHERE lower(email) = lower($1)
-- Covering index — includes all columns needed to satisfy query
-- Avoids table heap fetch entirely (index-only scan)
CREATE INDEX idx_orders_covering ON orders (status, order_date DESC)
INCLUDE (user_id, total_amount);
GIN Index for JSONB and Full-Text Search
-- GIN index for JSONB queries
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
-- Usage: WHERE metadata @> '{"category": "electronics"}'
-- Full-text search index
CREATE INDEX idx_products_fts ON products
USING GIN (to_tsvector('english', name || ' ' || description));
-- Usage: WHERE to_tsvector('english', name || ' ' || description) @@ plainto_tsquery('wireless headphones')
Index Maintenance
-- Identify unused indexes (candidates for removal)
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE '%pkey' -- Exclude primary keys
ORDER BY pg_relation_size(indexrelid) DESC;
-- Identify bloated indexes (need REINDEX)
SELECT
indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
Connection Pooling with PgBouncer
Each PostgreSQL connection consumes ~5–10MB of server memory and requires process-level overhead. At 500+ concurrent connections, memory pressure degrades performance significantly. Connection pooling multiplexes many application connections through a smaller pool of actual database connections.
PgBouncer is the standard PostgreSQL connection pooler. It operates in three modes:
Session mode: One server connection per client session. Maintains session state (prepared statements, SET variables). Use when application uses session-level features.
Transaction mode: Server connection held only during a transaction. More efficient than session mode. Does not support prepared statements, advisory locks, or LISTEN/NOTIFY.
Statement mode: Server connection held for a single statement. Maximum efficiency. Only suitable for simple applications.
# pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
pool_mode = transaction
max_client_conn = 1000 # Total client connections PgBouncer accepts
default_pool_size = 25 # Server connections per database/user pair
min_pool_size = 5
reserve_pool_size = 5
server_idle_timeout = 600
Sizing the pool: The optimal server-side connection count is approximately 2 × CPU_cores + effective_io_concurrency. For a 4-core server: 8–12 connections. PgBouncer multiplexes up to 1,000 client connections through this pool in transaction mode.
PostgreSQL Configuration Optimization
Default PostgreSQL configuration is conservative to run on hardware as small as 256MB RAM. Production servers need tuning.
Memory Configuration
# postgresql.conf — for 32GB RAM server
shared_buffers = 8GB # 25% of total RAM
effective_cache_size = 24GB # Estimate of OS + Postgres cache (75% of RAM)
work_mem = 256MB # Per sort/hash operation (×max_connections×workers)
maintenance_work_mem = 2GB # For VACUUM, CREATE INDEX operations
wal_buffers = 64MB # WAL write buffer
work_mem warning: work_mem is allocated per operation, not per connection. A single query with 5 sort operations can use 5×work_mem. At 100 connections, the theoretical maximum memory use is 100 × 5 × 256MB = 128GB (more than the server has). Set work_mem conservatively for shared servers; use SET work_mem = '1GB' within specific sessions that need more.
WAL and Checkpoint Configuration
wal_level = replica # Required for streaming replication
max_wal_size = 4GB # Allow larger WAL before checkpoint
checkpoint_completion_target = 0.9 # Spread checkpoint I/O over 90% of checkpoint interval
synchronous_commit = on # For production (off for performance test only)
VACUUM Configuration
VACUUM prevents table bloat and maintains index performance. Autovacuum should run automatically; the default configuration is conservative for small tables.
autovacuum_vacuum_scale_factor = 0.02 # Run after 2% of rows change (default 20%)
autovacuum_analyze_scale_factor = 0.01 # Analyze after 1% of rows change
autovacuum_vacuum_cost_delay = 2ms # Reduce I/O throttling for faster vacuum
autovacuum_max_workers = 4 # Parallel autovacuum workers
Table Partitioning for Large Tables
Partitioning divides a large table into smaller physical segments. PostgreSQL's declarative partitioning (PostgreSQL 10+) enables partition pruning — the query planner reads only relevant partitions.
-- Range partitioning by date (for time-series operational data)
CREATE TABLE events (
id BIGSERIAL,
entity_id INTEGER NOT NULL,
event_type VARCHAR(50),
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Create monthly partitions
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- ... and so on
-- Indexes are created per partition
CREATE INDEX ON events_2026_01 (entity_id, created_at DESC);
CREATE INDEX ON events_2026_02 (entity_id, created_at DESC);
Partitioning benefits for large tables:
DELETEon old partitions isDROP TABLEinternally — fast, no VACUUM needed- Partition pruning limits scans to relevant partitions:
WHERE created_at >= '2026-03-01'reads only the March partition - Parallel query workers process partitions independently
Replication and Read Scaling
PostgreSQL streaming replication creates read replicas that receive WAL changes from the primary in real time.
-- On primary: postgresql.conf
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB
-- Create replication user
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'secure_password';
Application-level read/write splitting:
# SQLAlchemy with read replica routing
from sqlalchemy import create_engine
write_engine = create_engine("postgresql://user:pass@primary-host/mydb")
read_engine = create_engine("postgresql://user:pass@replica-host/mydb",
execution_options={"postgresql_readonly": True})
# Use read_engine for SELECT-only queries
with read_engine.connect() as conn:
results = conn.execute(text("SELECT * FROM products WHERE category = :cat"), {"cat": "electronics"})
# Use write_engine for all writes
with write_engine.begin() as conn:
conn.execute(text("INSERT INTO orders ..."))
Replication lag monitoring:
-- On primary: check replica lag
SELECT
client_addr,
state,
sent_lsn - write_lsn AS write_lag_bytes,
sent_lsn - flush_lsn AS flush_lag_bytes,
sent_lsn - replay_lsn AS replay_lag_bytes
FROM pg_stat_replication;
Connection Monitoring and Query Diagnostics
Beyond the core performance techniques, production PostgreSQL requires ongoing monitoring of connection state and query behavior.
Lock monitoring: Long-running locks from unfinished transactions block other queries. Identify and terminate blocking sessions:
-- Find queries blocked by locks
SELECT
pid,
now() - query_start AS duration,
wait_event_type,
wait_event,
state,
LEFT(query, 60) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY duration DESC;
-- Terminate a specific blocking query (use with caution)
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE pid = 12345;
Table bloat detection: Dead tuples from DELETE and UPDATE operations accumulate until VACUUM reclaims them. High bloat degrades query performance and increases storage:
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
n_dead_tup,
n_live_tup,
ROUND(100 * n_dead_tup::numeric / NULLIF(n_live_tup, 0), 1) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
A dead tuple percentage above 10% on a frequently-written table indicates autovacuum is not keeping up. Tune autovacuum_vacuum_scale_factor downward for high-write tables.
Checkpoint monitoring: Checkpoint I/O spikes cause periodic latency bursts. Check checkpoint frequency:
SELECT checkpoints_timed, checkpoints_req, checkpoint_write_time,
checkpoint_sync_time, buffers_checkpoint
FROM pg_stat_bgwriter;
High checkpoints_req (requested checkpoints exceeding the scheduled interval) indicates WAL fills between checkpoints — increase max_wal_size.
Common PostgreSQL Performance Issues
| Issue | Diagnostic | Solution |
|---|---|---|
| Slow queries despite indexes | EXPLAIN ANALYZE shows Seq Scan |
Check index exists and is not suppressed by implicit cast or function |
| High connection count | SELECT count(*) FROM pg_stat_activity |
Deploy PgBouncer in transaction mode |
| Table bloat | pg_size_pretty(pg_total_relation_size(...)) vs actual row count |
Manual VACUUM FULL then tune autovacuum |
| N+1 queries from ORM | High call count in pg_stat_statements | Use eager loading; batch queries |
| Checkpoint spikes | High write latency at regular intervals | Increase max_wal_size, adjust checkpoint_completion_target |
PostgreSQL Performance Checklist
A systematic approach prevents overlooking critical optimizations:
| Category | Check | Tool |
|---|---|---|
| Slow queries | Top 20 by total execution time | pg_stat_statements |
| Index usage | Tables with high seq_scan / low idx_scan ratio | pg_stat_user_tables |
| Missing indexes | Queries with large rows examined vs rows returned | EXPLAIN ANALYZE |
| Unused indexes | Indexes with zero idx_scan (excluding primary keys) | pg_stat_user_indexes |
| Table bloat | Tables with dead_pct > 10% | pg_stat_user_tables |
| Connection count | Active connections approaching max_connections | pg_stat_activity |
| Lock contention | Queries waiting on locks for > 5 seconds | pg_stat_activity |
| Checkpoint frequency | checkpoints_req increasing faster than checkpoints_timed | pg_stat_bgwriter |
Running this checklist monthly on a production PostgreSQL instance identifies performance regressions before users report them. For systems handling more than 1,000 queries per second, automated alerting on each metric with defined thresholds is more reliable than manual review.
Conclusion
PostgreSQL performance optimization is a layered discipline. At the query level, pg_stat_statements and EXPLAIN ANALYZE identify what is slow and why. At the schema level, the right index types (B-Tree, GIN, partial, covering) eliminate unnecessary data access. At the infrastructure level, PgBouncer eliminates connection overhead, partitioning enables fast data lifecycle management, and streaming replication scales read workloads horizontally.
The most impactful single change in most underperforming PostgreSQL deployments is index design — specifically, adding covering indexes for the most frequent query patterns and removing unused indexes that slow down writes without helping reads.
Author: Smart Maple Database Engineering Team Updated: April 2026
Related Articles
MLOps Guide: Taking Machine Learning Models to Production [2026]
87% of machine learning models built by data science teams never reach production. The models work — they pass cross-validation, they score well on holdout sets, they demonstrate genuine predictive value. The problem is not the modeling. The problem is everything that happens between a notebook experiment and a reliable, monitored, production system. MLOps is the discipline that closes that gap. This guide covers the full MLOps stack: maturity levels, tooling choices (MLflow, DVC, Kubeflow
Read MoreLLM Fine-Tuning Guide: Custom Model Training with LoRA and QLoRA [2026]
General-purpose LLMs are impressive. They can write code, summarize documents, answer questions, and translate between languages with reasonable accuracy. But "reasonable" is not good enough when your application requires consistent output format, domain-specific terminology, a particular tone, or behavior that the base model was never trained to exhibit. That gap is where fine-tuning matters. Fine-tuning updates a model's weights on your specific data, changing how the model behaves — not
Read MoreComputer Vision Applications: Object Detection, OCR, and Industrial AI [2026]
Computer vision has moved well past the research phase. The models are trained, the frameworks are mature, the hardware is accessible, and the use cases are generating measurable returns. What was a specialized capability requiring deep expertise in 2018 is now deployable infrastructure — if you know which component to reach for and where the real complexity lives. This guide covers computer vision applications across industrial, medical, logistics, and document processing domains. It expl
Read More
