February 25, 2026 · 9 min · 1770 words · Rob Washington
Table of Contents
PostgreSQL is fast out of the box. But “fast enough for development” and “fast enough for production” are different conversations. These techniques will help you find and fix performance bottlenecks.
-- Enable the extension
CREATEEXTENSIONIFNOTEXISTSpg_stat_statements;-- Top 10 queries by total time
SELECTround(total_exec_time::numeric,2)AStotal_time_ms,calls,round(mean_exec_time::numeric,2)ASmean_time_ms,round((100*total_exec_time/sum(total_exec_time)OVER())::numeric,2)ASpercent,substring(query,1,100)ASqueryFROMpg_stat_statementsORDERBYtotal_exec_timeDESCLIMIT10;-- Queries with high average time (potential optimization targets)
SELECTcalls,round(mean_exec_time::numeric,2)ASmean_ms,substring(query,1,100)FROMpg_stat_statementsWHEREcalls>100ORDERBYmean_exec_timeDESCLIMIT10;-- Reset statistics (after optimization)
SELECTpg_stat_statements_reset();
-- Index only active orders
CREATEINDEXidx_orders_activeONorders(customer_id)WHEREstatus='active';-- Much smaller index, faster queries on active orders
-- For case-insensitive searches
CREATEINDEXidx_users_email_lowerONusers(LOWER(email));-- Query must match exactly
SELECT*FROMusersWHERELOWER(email)='test@example.com';
-- Include columns in the index
CREATEINDEXidx_orders_coveringONorders(customer_id)INCLUDE(total,status);-- This query can be satisfied entirely from the index
SELECTcustomer_id,total,statusFROMordersWHEREcustomer_id=123;
-- Bad: counts all matching rows
SELECTCASEWHENCOUNT(*)>0THENtrueELSEfalseENDFROMordersWHEREcustomer_id=123;-- Good: stops at first match
SELECTEXISTS(SELECT1FROMordersWHEREcustomer_id=123);
-- Bad: many round trips
INSERTINTOlogs(message)VALUES('msg1');INSERTINTOlogs(message)VALUES('msg2');INSERTINTOlogs(message)VALUES('msg3');-- Good: single statement
INSERTINTOlogs(message)VALUES('msg1'),('msg2'),('msg3');-- Or use COPY for bulk loads
COPYlogs(message)FROM'/tmp/data.csv'WITHCSV;
-- CTEs are optimization barriers before PostgreSQL 12
-- In 12+, they can be inlined
-- Force materialization when needed
WITHorder_totalsASMATERIALIZED(SELECTcustomer_id,SUM(total)AStotalFROMordersGROUPBYcustomer_id)SELECT*FROMorder_totalsWHEREtotal>1000;
-- shared_buffers: 25% of RAM for dedicated server
ALTERSYSTEMSETshared_buffers='4GB';-- effective_cache_size: 50-75% of RAM (hint for planner)
ALTERSYSTEMSETeffective_cache_size='12GB';-- work_mem: per-operation memory (sorts, hashes)
-- Be careful: can multiply with parallel queries
ALTERSYSTEMSETwork_mem='256MB';-- maintenance_work_mem: for VACUUM, CREATE INDEX
ALTERSYSTEMSETmaintenance_work_mem='1GB';
-- Max connections (lower is often better, use connection pooling)
ALTERSYSTEMSETmax_connections=200;-- Use PgBouncer or similar for connection pooling
-- Enable parallel queries (default in modern PostgreSQL)
ALTERSYSTEMSETmax_parallel_workers_per_gather=4;ALTERSYSTEMSETmax_parallel_workers=8;ALTERSYSTEMSETparallel_tuple_cost=0.01;ALTERSYSTEMSETparallel_setup_cost=100;
PostgreSQL uses MVCC—old row versions aren’t deleted immediately. VACUUM reclaims space and prevents transaction ID wraparound.
1
2
3
4
5
6
7
8
9
10
11
-- Manual vacuum
VACUUMVERBOSEorders;-- Vacuum and reclaim space to OS
VACUUMFULLorders;-- Locks table, use carefully
-- Update statistics for query planner
ANALYZEorders;-- Both
VACUUMANALYZEorders;
-- Unused indexes (candidates for removal)
SELECTschemaname,relname,indexrelname,idx_scanFROMpg_stat_user_indexesWHEREidx_scan=0ANDindexrelnameNOTLIKE'%_pkey'ORDERBYpg_relation_size(indexrelid)DESC;-- Index size
SELECTindexrelname,pg_size_pretty(pg_relation_size(indexrelid))ASsizeFROMpg_stat_user_indexesORDERBYpg_relation_size(indexrelid)DESC;
SELECTsum(heap_blks_read)ASheap_read,sum(heap_blks_hit)ASheap_hit,round(sum(heap_blks_hit)*100.0/nullif(sum(heap_blks_hit)+sum(heap_blks_read),0),2)ASratioFROMpg_statio_user_tables;-- Should be > 99% for a well-tuned database