SQL Query Optimization: PostgreSQL Performance Deep Dive
Slow queries are the most common performance bottleneck in web applications. SQL query optimization in PostgreSQL requires understanding EXPLAIN ANALYZE, choosing the right index types, and knowing when the planner makes suboptimal decisions. This guide covers practical techniques for diagnosing and fixing slow queries in production, including how the cost-based planner reasons about your data and where it most often goes wrong.
Before you change a single index, it helps to understand the workflow most experienced teams follow. First, you find the offending queries with pg_stat_statements, which aggregates execution counts and total time across the whole cluster. Then you reproduce the slowest statements under EXPLAIN ANALYZE to see what the executor actually did. Only then do you reach for indexes, rewrites, or configuration changes. Skipping the measurement step is the single biggest reason optimization efforts waste time.
EXPLAIN ANALYZE: Your Primary Diagnostic Tool
EXPLAIN shows the planner’s estimated plan, while ANALYZE actually runs the query and reports real timings and row counts. The gap between estimated and actual rows is the most important signal you can read. When the planner estimates 10 rows but the node returns 100,000, it almost certainly chose a nested loop where a hash join would have been faster, and the root cause is usually stale statistics.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2026-01-01' AND o.status = 'completed'
ORDER BY o.created_at DESC LIMIT 50;
-- Key metrics to check:
-- 1. Actual vs estimated rows (big gap = stale statistics)
-- 2. Buffers: shared hit vs read (reads = disk I/O)
-- 3. Sort Method: quicksort vs external merge (external = low work_mem)
-- 4. Loops count (high loops = consider hash join)
The BUFFERS option is underused but invaluable. A shared hit means the page was already in the buffer cache, while a read means PostgreSQL went to disk. For example, a query showing thousands of reads on repeat executions points to a working set larger than shared_buffers, not a missing index. Therefore, always run a query twice and compare; the second run reveals the steady-state cache behavior your users actually experience.
Choosing the Right Index Type
Indexes are not free. Each one slows down writes, consumes disk, and must be kept consistent on every INSERT, UPDATE, and DELETE. Consequently, the goal is the smallest set of indexes that covers your actual query patterns. PostgreSQL offers several index types, and matching the type to the access pattern is where most of the leverage lives.
-- B-tree: equality and range queries (default)
CREATE INDEX idx_orders_status ON orders(status);
-- Composite: column ORDER matters!
CREATE INDEX idx_orders_status_date ON orders(status, created_at DESC);
-- Partial: index only rows you query
CREATE INDEX idx_active_orders ON orders(created_at DESC)
WHERE status IN ('pending', 'processing');
-- Covering: index-only scan (no table lookup)
CREATE INDEX idx_orders_covering ON orders(customer_id)
INCLUDE (total, status, created_at);
-- GIN: arrays, JSONB, full-text search
CREATE INDEX idx_products_tags ON products USING GIN(tags);
CREATE INDEX idx_events_data ON events USING GIN(payload jsonb_path_ops);
-- GiST: geometry, range types, nearest-neighbor
CREATE INDEX idx_locations ON locations USING GiST(coordinates);
-- BRIN: naturally ordered data (timestamps) — much smaller than B-tree
CREATE INDEX idx_logs_created ON logs USING BRIN(created_at);
Composite index column order follows the “leftmost prefix” rule. An index on (status, created_at) can serve queries filtering on status alone or on both columns, but it cannot efficiently serve a query that filters only on created_at. As a practical heuristic, put equality-filtered columns first and range-filtered or sort columns last. Notably, a covering index with INCLUDE lets PostgreSQL answer the query entirely from the index, skipping the heap fetch altogether — an index-only scan that can cut latency dramatically for read-heavy endpoints.
BRIN indexes deserve special mention for append-only tables. On a billion-row log table ordered by time, a BRIN index might occupy a few hundred kilobytes where a B-tree would need many gigabytes. The trade-off is precision: BRIN stores only min/max ranges per block, so it shines only when physical row order correlates with the indexed column.
Common Query Anti-Patterns
Many slow queries are not index problems at all but query-shape problems. The most common is wrapping an indexed column in a function, which forces a sequential scan because the planner cannot match the expression to the index. Rewriting the predicate as a sargable range restores index usage without changing results.
-- Anti-pattern 1: Function on indexed column
-- BAD: SELECT * FROM orders WHERE DATE(created_at) = '2026-01-15';
-- GOOD:
SELECT * FROM orders
WHERE created_at >= '2026-01-15' AND created_at < '2026-01-16';
-- Anti-pattern 2: SELECT * when you need 2 columns
-- BAD: SELECT * FROM orders WHERE customer_id = 123;
-- GOOD: SELECT id, total FROM orders WHERE customer_id = 123;
-- Anti-pattern 3: NOT IN with subquery
-- BAD: WHERE id NOT IN (SELECT customer_id FROM orders);
-- GOOD:
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL;
-- Anti-pattern 4: OR preventing index usage
-- BAD: WHERE category = 'electronics' OR price < 10;
-- GOOD: Use UNION ALL with separate indexes
The NOT IN case is subtle and dangerous. If the subquery returns even a single NULL, the entire predicate evaluates to unknown and you silently get zero rows. The LEFT JOIN ... IS NULL rewrite is both NULL-safe and usually faster because the planner can use an anti-join. Furthermore, replacing SELECT * with the exact columns you need is what unlocks index-only scans in the first place, so the two patterns reinforce each other.
Window Functions and Pagination
Window functions compute running totals, rankings, and per-group aggregates in a single pass, avoiding the correlated subqueries that quietly turn into O(n²) work. They keep all rows visible, unlike GROUP BY, which collapses them.
-- Efficient ranking without subqueries
SELECT id, customer_id, total,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rank,
SUM(total) OVER (PARTITION BY customer_id) AS customer_total
FROM orders WHERE created_at > NOW() - INTERVAL '30 days';
-- Keyset pagination (don't use OFFSET for large datasets)
-- BAD: SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 100000;
-- GOOD:
SELECT * FROM products WHERE id > 100000 ORDER BY id LIMIT 20;
OFFSET pagination has a hidden cost: to return page 5,000 the database must read and discard every preceding row. As a result, response time grows linearly with page depth, and the last pages of a large catalog can be hundreds of times slower than the first. Keyset pagination (also called "seek" pagination) instead remembers the last seen key and asks for rows after it, so every page is equally fast. The trade-off is that you lose random "jump to page N" access, which is usually an acceptable price for infinite-scroll and API cursors.
Partitioning for Large Tables
When a single table grows past tens of millions of rows, declarative partitioning splits it into smaller physical tables the planner can prune. A query filtered on the partition key touches only the relevant partitions, and dropping old data becomes an instant DETACH instead of a slow, bloat-inducing DELETE.
-- Range partitioning for time-series data
CREATE TABLE events (
id BIGSERIAL, event_type TEXT NOT NULL,
payload JSONB, created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- Auto-create partitions with pg_partman
SELECT partman.create_parent('public.events', 'created_at', 'range', '1 month', p_premake := 3);
Partitioning is not a universal win, however. It adds planning overhead, every unique constraint must include the partition key, and queries that cannot prune end up scanning all partitions — sometimes slower than the original single table. Therefore, partition only when you have a clear partition key that most queries filter on, and benchmark before committing.
Tuning the Planner and Configuration
Sometimes the query and indexes are fine but the planner still chooses poorly because its statistics or memory budget are wrong. Two settings deliver outsized returns. First, work_mem controls how much memory each sort or hash operation may use before spilling to disk; an "external merge" sort method in your EXPLAIN output is a direct signal to raise it. Second, the planner relies on table statistics gathered by ANALYZE, and these go stale after bulk loads.
-- Refresh statistics after large data changes
ANALYZE orders;
-- Increase sampling for skewed columns the planner mis-estimates
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- Per-session memory for a heavy reporting query
SET work_mem = '256MB';
-- Find the slowest queries cluster-wide
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 20;
For low-cardinality columns with skewed distributions, raising the statistics target helps the planner estimate selectivity accurately. Moreover, multi-column correlations (for example, city and postal code) can be captured with CREATE STATISTICS so the planner stops multiplying independent probabilities and producing wildly low row estimates. These extended statistics often fix mysterious nested-loop blowups that no amount of indexing resolves.
When NOT to Optimize
Not every slow query deserves attention, and premature indexing can hurt. A report that runs once a day at 3 a.m. and takes two seconds is not a problem worth an index that slows down every checkout write. Additionally, adding indexes to a write-heavy table can degrade throughput more than the read speedup justifies, so weigh the full workload, not just the query in front of you.
There are also problems SQL tuning cannot solve. If your application issues one query per row in a loop, no index fixes the N+1 pattern — that belongs in the application layer, often paired with a cache. For complementary strategies, see the broader discussion in Database Indexing Strategies and caching patterns in Redis Caching Strategies.
For further reading, refer to the PostgreSQL official documentation and the Redis documentation for comprehensive reference material.
Key Takeaways
- Find the worst offenders with pg_stat_statements before touching anything
- Read actual-vs-estimated rows in EXPLAIN ANALYZE to spot stale statistics
- Match index type to access pattern; the smallest correct set beats the most
- Rewrite non-sargable predicates and prefer keyset over OFFSET pagination
- Partition and tune work_mem only when measurements justify the complexity
In conclusion, Sql Query Optimization Postgresql is an essential discipline that rewards a measure-first mindset. By diagnosing with EXPLAIN ANALYZE, choosing indexes deliberately, eliminating anti-patterns, and tuning the planner only where data proves it necessary, you build databases that stay fast as they grow. Start with the fundamentals, iterate on real production traffic, and continuously measure results so every change earns its place.