Performance Optimization
Reading EXPLAIN Plans
Performance tuning in Postgres always starts in the same place: reading the query plan. You can't fix what you don't understand, and EXPLAIN is how Postgres tells you exactly what it's doing with your query — and why it's slow.
There are two forms, and the difference matters:
-- Estimated plan only (does NOT execute the query) EXPLAIN SELECT * FROM orders WHERE customer_id = 42; -- Actually executes and shows real timing (use this!) EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN shows what the planner thinks will happen. EXPLAIN ANALYZE shows what actually happened. When they disagree, that's a clue — the planner is working with bad estimates.
Here's an annotated plan from a real query:
QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------ Hash Join (cost=1204.52..5765.83 rows=1248 width=164) (actual time=8.412..42.891 rows=1195 loops=1) Hash Cond: (o.customer_id = c.id) -> Seq Scan on orders o (cost=0.00..4230.00 rows=50000 width=96) (actual time=0.015..18.234 rows=50000 loops=1) Filter: (created_at > '2025-01-01'::date) Rows Removed by Filter: 150000 -> Hash (cost=1102.00..1102.00 rows=8202 width=68) (actual time=8.102..8.102 rows=8200 loops=1) Buckets: 16384 Batches: 1 Memory Usage: 892kB -> Seq Scan on customers c (cost=0.00..1102.00 rows=8202 width=68) (actual time=0.012..4.521 rows=8200 loops=1) Filter: (region = 'us-west') Rows Removed by Filter: 41800 Planning Time: 0.285 ms Execution Time: 43.124 ms
The key fields to understand:
- cost (e.g.,
0.00..4230.00): Startup cost and total cost in arbitrary planner units. The first number is the cost to return the first row; the second is total cost for all rows. - rows: Estimated (in the parenthetical) vs actual row count. Large differences signal stale statistics.
- actual time: Real milliseconds. First number is time to first row; second is total time.
- loops: How many times this node executed. Multiply
actual time × loopsfor true cost. - Rows Removed by Filter: Rows read but discarded — a high number means wasted work.
Join Types in Plans
Postgres chooses between three join strategies:
-- Nested Loop: best for small inner tables or index-driven lookups EXPLAIN ANALYZE SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.id = 12345;
Nested Loop (cost=0.71..16.75 rows=1 width=42) (actual time=0.038..0.041 rows=1 loops=1) -> Index Scan using orders_pkey on orders o (cost=0.42..8.44 rows=1 width=8) (actual time=0.021..0.022 rows=1 loops=1) Index Cond: (id = 12345) -> Index Scan using customers_pkey on customers c (cost=0.29..8.31 rows=1 width=42) (actual time=0.012..0.013 rows=1 loops=1) Index Cond: (id = o.customer_id) Planning Time: 0.195 ms Execution Time: 0.068 ms
- Nested Loop: Iterates outer rows, looks up each in the inner table. Fast when inner table has an index or few rows.
- Hash Join: Builds a hash table from the smaller relation, probes it with the larger. Good for joining large sets without indexes.
- Merge Join: Sorts both inputs and merges. Efficient when both inputs are already sorted or when the join is very large.
Spotting Planner Misestimates
When "rows" (estimated) and "actual rows" differ by 10x or more, the planner is working with bad data:
Index Scan using idx_orders_status on orders (cost=0.42..85.10 rows=12 width=96) (actual time=0.025..145.882 rows=48500 loops=1) Index Cond: (status = 'pending')
The planner expected 12 rows but got 48,500. This usually means:
- Statistics are stale — run
ANALYZE orders; - Correlation between columns that the planner can't model
- A recently loaded batch that changed the data distribution
Understanding Scan Types
The scan type in your EXPLAIN output tells you how Postgres is accessing the table data. Each type has a sweet spot, and understanding when Postgres chooses each one helps you know whether you need to intervene.
Sequential Scan (Seq Scan)
Reads the entire table from start to finish. This is the baseline — no index needed, no random I/O, just read everything.
EXPLAIN ANALYZE SELECT count(*) FROM events WHERE event_type = 'page_view';
Aggregate (cost=28450.00..28450.01 rows=1 width=8) (actual time=187.234..187.235 rows=1 loops=1) -> Seq Scan on events (cost=0.00..26200.00 rows=900000 width=0) (actual time=0.021..142.891 rows=892410 loops=1) Filter: (event_type = 'page_view') Rows Removed by Filter: 107590 Execution Time: 187.301 ms
Seq Scan is the correct choice when:
- The table is small (a few thousand rows)
- You're reading most of the table anyway (low selectivity)
- No useful index exists
Seq Scan is a problem when:
- You're selecting a tiny fraction of a large table
- It's in a hot path that runs thousands of times per second
Index Scan
Uses an index to find matching rows, then fetches full row data from the heap (table) pages.
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 8842;
Index Scan using idx_orders_customer_id on orders (cost=0.42..52.18 rows=45 width=96) (actual time=0.028..0.185 rows=42 loops=1) Index Cond: (customer_id = 8842) Planning Time: 0.082 ms Execution Time: 0.214 ms
Index Scan involves two steps: traverse the index B-tree, then jump to heap pages. It's fast for highly selective queries but involves random I/O to the heap.
Index Only Scan
The holy grail — Postgres satisfies the query entirely from the index without touching the heap table at all.
-- With a covering index: CREATE INDEX idx_orders_cust_covering ON orders(customer_id) INCLUDE (status, total); EXPLAIN ANALYZE SELECT status, total FROM orders WHERE customer_id = 8842;
Index Only Scan using idx_orders_cust_covering on orders (cost=0.42..48.12 rows=45 width=18) (actual time=0.022..0.078 rows=42 loops=1) Index Cond: (customer_id = 8842) Heap Fetches: 0 Planning Time: 0.075 ms Execution Time: 0.102 ms
"Heap Fetches: 0" means zero trips to the table. This only works when:
- All columns in SELECT, WHERE, and ORDER BY are in the index (via INCLUDE)
- The visibility map shows all pages are all-visible (meaning VACUUM has run recently)
Bitmap Index Scan + Bitmap Heap Scan
Bitmap scans bridge the gap between seq scan and index scan. They're chosen for medium selectivity — too many rows for an index scan (random I/O gets expensive), but too few for a seq scan to be optimal.
EXPLAIN ANALYZE SELECT * FROM orders WHERE created_at BETWEEN '2025-03-01' AND '2025-03-31';
Bitmap Heap Scan on orders (cost=285.42..8924.15 rows=15200 width=96) (actual time=2.841..28.412 rows=14892 loops=1) Recheck Cond: ((created_at >= '2025-03-01') AND (created_at <= '2025-03-31')) Heap Blocks: exact=4218 -> Bitmap Index Scan on idx_orders_created_at (cost=0.00..281.62 rows=15200 width=0) (actual time=2.124..2.124 rows=14892 loops=1) Index Cond: ((created_at >= '2025-03-01') AND (created_at <= '2025-03-31')) Planning Time: 0.108 ms Execution Time: 29.891 ms
The two-phase approach:
- Bitmap Index Scan: Walks the index and builds an in-memory bitmap of which heap pages contain matching rows
- Bitmap Heap Scan: Reads those heap pages sequentially (converting random I/O into sequential I/O)
This is especially powerful when combining multiple indexes with BitmapAnd/BitmapOr:
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending' AND region = 'eu-west';
Bitmap Heap Scan on orders (cost=412.85..2180.42 rows=820 width=96) (actual time=1.892..5.421 rows=795 loops=1) Recheck Cond: ((status = 'pending') AND (region = 'eu-west')) -> BitmapAnd -> Bitmap Index Scan on idx_orders_status (cost=0.00..205.12 rows=12500 width=0) (actual time=0.845..0.845 rows=12500 loops=1) -> Bitmap Index Scan on idx_orders_region (cost=0.00..198.42 rows=8200 width=0) (actual time=0.712..0.712 rows=8200 loops=1) Execution Time: 5.612 ms
When Does Postgres Choose Each?
The decision comes down to selectivity — what fraction of the table you're reading:
| Selectivity | Typical Scan | Reasoning |
|---|---|---|
| < 1-2% of rows | Index Scan | Few rows; random I/O is acceptable |
| 2-15% of rows | Bitmap Scan | Too many for index, too few for seq scan |
| > 15-20% of rows | Seq Scan | Sequential I/O wins for large fractions |
| All indexed columns available | Index Only Scan | Avoids heap entirely |
These thresholds shift depending on table size, correlation, and whether data is cached in shared_buffers.
Memory and I/O Configuration
Postgres ships with conservative defaults designed to run on a 1990s-era shared hosting server with 128 MB of RAM. If you haven't tuned these settings, you're leaving enormous performance on the table. The good news: a handful of parameters cover 90% of what matters.
shared_buffers
This is Postgres's own cache — the pool of 8KB pages kept in shared memory. Every read goes through this cache first before hitting the OS page cache or disk.
Rule of thumb: 25% of total RAM, but with diminishing returns past about 8 GB on most workloads.
-- Check current setting SHOW shared_buffers; -- Recommended: 25% of RAM -- 16 GB server → set shared_buffers = '4GB' -- 64 GB server → set shared_buffers = '16GB'
effective_cache_size
This doesn't allocate memory — it tells the planner how much data is likely cached between shared_buffers and the OS page cache. A higher value makes the planner more willing to choose index scans (since it expects the data is already in memory).
Rule of thumb: 50-75% of total RAM.
SHOW effective_cache_size; -- 64 GB server → set effective_cache_size = '48GB'
work_mem
Memory available for each sort, hash, or merge operation within a query. A single complex query might use work_mem multiple times (once per sort node, once per hash join). Set this too high and a burst of concurrent queries can exhaust RAM.
Rule of thumb: Start at 16-64 MB, increase for specific sessions running analytical queries.
-- Check current setting SHOW work_mem; -- For an analytical session that needs large sorts: SET work_mem = '256MB'; -- Run your big query... RESET work_mem;
Signs that work_mem is too low: you see "Sort Method: external merge Disk" or "Batches: 4" (multiple batches) in hash joins in your EXPLAIN ANALYZE output.
maintenance_work_mem
Used by VACUUM, CREATE INDEX, and ALTER TABLE operations. These run single-threaded so you can safely set this much higher than work_mem.
SHOW maintenance_work_mem; -- Set to 1-2 GB for faster index builds and vacuums -- ALTER SYSTEM SET maintenance_work_mem = '1GB';
random_page_cost
The planner's estimate of how expensive a random page read is relative to a sequential read. The default of 4.0 assumes spinning disks. On SSDs, random reads are nearly as fast as sequential reads.
For SSDs: set to 1.1 - 1.5. This makes the planner more willing to use index scans.
SHOW random_page_cost; -- ALTER SYSTEM SET random_page_cost = 1.1;
Monitoring Your Cache Hit Ratio
This is the single most important metric for memory tuning. You want shared_buffers cache hit ratio above 99%:
SELECT sum(heap_blks_read) AS heap_read, sum(heap_blks_hit) AS heap_hit, round( sum(heap_blks_hit)::numeric / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100, 2 ) AS cache_hit_ratio FROM pg_statio_user_tables;
heap_read | heap_hit | cache_hit_ratio -----------+------------+----------------- 285412 | 89421058 | 99.68
If your cache hit ratio drops below 99%, you're hitting disk too often. Either increase shared_buffers or reduce working set size (better indexes, partitioning, archiving old data).
Checkpoint Tuning
Checkpoints flush dirty pages from shared_buffers to disk. Poorly configured checkpoints cause I/O spikes that stall queries.
-- Check current checkpoint settings SELECT name, setting, unit FROM pg_settings WHERE name IN ('max_wal_size', 'checkpoint_completion_target', 'checkpoint_timeout');
name | setting | unit ---------------------------+---------+------ checkpoint_completion_target | 0.9 | checkpoint_timeout | 300 | s max_wal_size | 2048 | MB
Key principles:
- max_wal_size: Controls how much WAL accumulates before forcing a checkpoint. Higher values = less frequent checkpoints = smoother I/O. Set to 4-16 GB for write-heavy workloads.
- checkpoint_completion_target: Spread checkpoint I/O over this fraction of the checkpoint interval. Default 0.9 is good (spreads over 90% of the interval).
Understanding Your Workload Profile
Before tuning, know whether you're read-heavy or write-heavy — the strategies differ:
SELECT datname, tup_returned + tup_fetched AS reads, tup_inserted + tup_updated + tup_deleted AS writes, round( (tup_returned + tup_fetched)::numeric / nullif(tup_inserted + tup_updated + tup_deleted, 1), 1 ) AS read_write_ratio FROM pg_stat_database WHERE datname = current_database();
datname | reads | writes | read_write_ratio -----------+------------+----------+------------------ myapp | 284521000 | 28120000 | 10.1
A 10:1 ratio is starting to get write-heavy. For context:
- Read-heavy (100:1+): Focus on caching,
effective_cache_size, covering indexes, read replicas - Balanced (10:1 to 100:1): Focus on shared_buffers, index optimization, connection pooling
- Write-heavy (<10:1): Focus on WAL tuning, checkpoint spreading,
fillfactor, HOT updates
HOT Updates and Fill Factor
When Postgres updates a row, it normally must update every index on the table (because the new row version lives at a new heap location). HOT (Heap Only Tuple) updates avoid this by storing the new version on the same heap page — but only when there's room.
-- Reserve 20% of each page for HOT updates on a frequently-updated table ALTER TABLE sessions SET (fillfactor = 80); -- After changing fillfactor, VACUUM FULL to rewrite the table (or wait for natural turnover) VACUUM FULL sessions;
Monitor HOT update effectiveness:
SELECT relname, n_tup_upd, n_tup_hot_upd, round(n_tup_hot_upd::numeric / nullif(n_tup_upd, 0) * 100, 1) AS hot_update_pct FROM pg_stat_user_tables WHERE n_tup_upd > 1000 ORDER BY n_tup_upd DESC;
relname | n_tup_upd | n_tup_hot_upd | hot_update_pct -------------+-----------+---------------+---------------- sessions | 892410 | 845100 | 94.7 orders | 245000 | 42100 | 17.2 user_prefs | 85200 | 81400 | 95.6
The orders table at 17.2% HOT updates is a candidate for a lower fillfactor — something is preventing in-page updates (likely an indexed column being updated, or pages already full).
aside positive Snowflake Postgres automatically tunes shared_buffers, work_mem, and checkpoint settings based on your instance size and workload patterns. For advanced tuning, Snowflake Postgres Insights surfaces your top queries by execution time and recommends index improvements.
pg_stat_statements and Query Analysis
If you only install one Postgres extension for performance, make it pg_stat_statements. It tracks execution statistics for every normalized query — how many times it ran, total time, mean time, rows returned, and buffer usage. This is how you find the queries that actually matter.
Enabling pg_stat_statements
-- Requires adding to shared_preload_libraries (needs restart) ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'; -- After restart: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
Finding Your Most Expensive Queries
The queries that consume the most total execution time are your highest-impact optimization targets:
SELECT substring(query, 1, 80) AS short_query, calls, round(total_exec_time::numeric, 1) AS total_time_ms, round(mean_exec_time::numeric, 1) AS mean_time_ms, rows, round((shared_blks_hit::numeric / nullif(shared_blks_hit + shared_blks_read, 0)) * 100, 1) AS cache_hit_pct FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
short_query | calls | total_time_ms | mean_time_ms | rows | cache_hit_pct ----------------------------------------------------+---------+---------------+--------------+----------+--------------- SELECT o.*, c.name FROM orders o JOIN customers c | 284521 | 892451.2 | 3.1 | 14218050 | 99.8 UPDATE sessions SET last_active = $1 WHERE id = $2 | 1842100 | 645210.8 | 0.4 | 1842100 | 99.9 SELECT * FROM events WHERE user_id = $1 AND crea | 42810 | 428100.5 | 10.0 | 4185200 | 87.2 INSERT INTO audit_log (action, user_id, details) | 892410 | 215420.1 | 0.2 | 892410 | 99.9 SELECT count(*) FROM orders WHERE status = $1 | 28450 | 185210.4 | 6.5 | 28450 | 92.1
Key insight: that UPDATE sessions query is individually cheap (0.4ms) but runs 1.8 million times — making it the second-most expensive overall. The events query at 87.2% cache hit rate is thrashing the buffer pool.
Total Time vs Mean Time
Don't just look at mean time. A query running 0.5ms × 2 million calls consumes more resources than one running 500ms × 100 calls:
-- Frequent but individually fast (death by a thousand cuts) SELECT substring(query, 1, 60) AS query, calls, round(mean_exec_time::numeric, 2) AS mean_ms, round(total_exec_time::numeric / 1000, 1) AS total_seconds FROM pg_stat_statements WHERE calls > 10000 ORDER BY total_exec_time DESC LIMIT 5;
-- Infrequent but individually slow (long-running reports) SELECT substring(query, 1, 60) AS query, calls, round(mean_exec_time::numeric, 1) AS mean_ms, round(total_exec_time::numeric / 1000, 1) AS total_seconds FROM pg_stat_statements WHERE mean_exec_time > 1000 ORDER BY mean_exec_time DESC LIMIT 5;
Both need attention, but through different approaches — high-frequency queries benefit from micro-optimizations (covering indexes, connection pooling), while slow queries need plan analysis.
Buffer Hit Ratio Per Query
The per-query buffer hit ratio reveals which specific queries are causing disk reads:
SELECT substring(query, 1, 60) AS query, calls, shared_blks_hit, shared_blks_read, round( shared_blks_hit::numeric / nullif(shared_blks_hit + shared_blks_read, 0) * 100, 1 ) AS buffer_hit_pct FROM pg_stat_statements WHERE shared_blks_read > 1000 ORDER BY shared_blks_read DESC LIMIT 5;
Queries with buffer hit rates below 95% are candidates for:
- Better indexes to reduce data scanned
- Increased
shared_buffersif the working set doesn't fit - Query rewrites to access less data
Resetting Statistics
Reset periodically to get fresh data — stats are cumulative since the last reset:
-- Reset all pg_stat_statements data SELECT pg_stat_statements_reset(); -- Check when stats were last reset (useful for context) SELECT stats_reset FROM pg_stat_bgwriter;
A good practice: reset after deployments or configuration changes so you can see the impact clearly.
Parallel Queries
Postgres can split expensive operations across multiple CPU cores. When a single query needs to read millions of rows, parallel execution can cut wall-clock time dramatically — but it's not magic, and not every query benefits.
When Postgres Goes Parallel
The planner considers parallel execution for:
- Large sequential scans (reading millions of rows)
- Hash joins on large datasets
- Aggregates (COUNT, SUM, AVG) over large tables
- Merge joins with sorted inputs
EXPLAIN ANALYZE SELECT count(*), avg(total) FROM orders WHERE created_at > '2025-01-01';
Finalize Aggregate (cost=15842.52..15842.53 rows=1 width=40) (actual time=48.125..50.412 rows=1 loops=1) -> Gather (cost=15842.10..15842.51 rows=2 width=40) (actual time=45.812..50.385 rows=3 loops=1) Workers Planned: 2 Workers Launched: 2 -> Partial Aggregate (cost=14842.10..14842.11 rows=1 width=40) (actual time=42.125..42.126 rows=1 loops=3) -> Parallel Seq Scan on orders (cost=0.00..13592.10 rows=250000 width=8) (actual time=0.024..28.412 rows=166420 loops=3) Filter: (created_at > '2025-01-01'::date) Rows Removed by Filter: 33580 Planning Time: 0.125 ms Execution Time: 50.512 ms
Key indicators: "Workers Planned: 2", "Workers Launched: 2", and "loops=3" (the leader process plus 2 workers). The "Parallel Seq Scan" distributes the table scan across all workers.
Configuration Parameters
-- Maximum parallel workers available system-wide SHOW max_parallel_workers; -- Default: 8 -- Maximum workers per single query operation (Gather node) SHOW max_parallel_workers_per_gather; -- Default: 2 -- Minimum table size before parallel is considered SHOW min_parallel_table_scan_size; -- Default: 8MB -- Cost thresholds that affect planner decisions SHOW parallel_setup_cost; -- Default: 1000 SHOW parallel_tuple_cost; -- Default: 0.1
To allow more parallelism for analytical workloads:
-- Allow up to 4 workers per query for heavy analytical work SET max_parallel_workers_per_gather = 4; EXPLAIN ANALYZE SELECT region, count(*), avg(total) FROM orders WHERE created_at > '2024-01-01' GROUP BY region;
Finalize GroupAggregate (cost=18425.12..18512.84 rows=12 width=48) (actual time=32.412..32.845 rows=12 loops=1) Group Key: region -> Gather Merge (cost=18425.12..18510.84 rows=48 width=48) (actual time=32.125..32.712 rows=60 loops=1) Workers Planned: 4 Workers Launched: 4 -> Sort (cost=17425.06..17425.09 rows=12 width=48) (actual time=28.412..28.415 rows=12 loops=5) Sort Key: region -> Partial HashAggregate (cost=17424.50..17424.62 rows=12 width=48) (actual time=28.125..28.132 rows=12 loops=5) -> Parallel Seq Scan on orders (cost=0.00..14925.00 rows=499900 width=16) (actual time=0.018..15.421 rows=399920 loops=5) Execution Time: 33.012 ms
With 4 workers, a 2-million row aggregation completes in 33ms instead of the ~130ms it would take single-threaded.
When Parallel Doesn't Help
Not everything benefits from parallelism:
-- Index scans are already fast — parallel doesn't help EXPLAIN SELECT * FROM orders WHERE id = 12345; -- Result: plain Index Scan, no parallelism (correct!) -- Small tables don't trigger parallel (below min_parallel_table_scan_size) EXPLAIN SELECT count(*) FROM status_codes; -- Result: plain Seq Scan, no parallelism (table is tiny)
Parallel execution is also avoided when:
- The function called in the query is marked
PARALLEL UNSAFE(e.g., writes data, uses cursors) - The query is inside a transaction that has written data
- The query uses
FOR UPDATE/FOR SHARE - The table is below
min_parallel_table_scan_size - Workers aren't available (all
max_parallel_workersslots are in use)
Parallel-Safe vs Parallel-Unsafe Functions
Custom functions must be marked as parallel-safe for the planner to parallelize queries that use them:
-- This function CAN run in parallel workers CREATE FUNCTION calculate_discount(price numeric, tier text) RETURNS numeric LANGUAGE sql PARALLEL SAFE AS $$ SELECT CASE tier WHEN 'gold' THEN price * 0.20 WHEN 'silver' THEN price * 0.10 ELSE price * 0.05 END; $$; -- This function CANNOT (it writes data) CREATE FUNCTION log_access(user_id int) RETURNS void LANGUAGE sql PARALLEL UNSAFE AS $$ INSERT INTO access_log(user_id, accessed_at) VALUES (user_id, now()); $$;
If a query uses a PARALLEL UNSAFE function, the entire query runs single-threaded — even parts that don't involve that function.
Conclusion
Performance optimization is an iterative process: measure with EXPLAIN ANALYZE and pg_stat_statements, identify the bottleneck (bad plan? missing index? insufficient memory? I/O bound?), fix it, and measure again. Resist the urge to tune everything at once — start with the highest-impact query from pg_stat_statements, understand its plan, and work from there.
Related Resources
Visual guide to understanding scan types and when they appear.
Understand your workload profile to guide tuning decisions.
Performance implications of large JSONB columns and TOAST.
Reduce index maintenance overhead with HOT updates.
This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances