PostgreSQL Query Optimizer
Master EXPLAIN ANALYZE, choose the right index type, find slow queries with pg_stat_statements, and systematically eliminate bottlenecks in PostgreSQL.
EXPLAIN ANALYZE — Reading the Output
-- Always use EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) for full detail EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.created_at > NOW() - INTERVAL '30 days' GROUP BY u.id ORDER BY order_count DESC LIMIT 20; -- Reading the output: -- "Seq Scan on users" → full table scan → may need index on created_at -- "cost=0.00..150.25" → estimated cost units (not ms) -- "actual time=0.042..12.340" → real ms for first row..all rows -- "rows=1234 width=64" → estimated rows -- "actual rows=1289 loops=1" → real rows (compare to estimate) -- "Buffers: shared hit=45 read=12" → 45 from cache, 12 from disk -- "Planning Time: 0.5ms" → query planning overhead -- "Execution Time: 12.8ms" → total execution time
Index Type Selection Guide
| Index Type | Best For | Operators |
|---|---|---|
| B-tree (default) | Most column types, range queries | =, <, >, BETWEEN, LIKE 'abc%' |
| Hash | High-cardinality equality only | = |
| GIN | JSONB, arrays, full-text search | @>, ?, @@, ANY |
| GiST | Geometry, ranges, full-text | &&, @>, <<| |
| BRIN | Large tables with natural order | =, <, > on ordered data |
Common Index Patterns
-- Partial index (smaller, faster for frequent filter patterns)
CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = 'pending'; -- only indexes pending rows
-- Functional / expression index
-- BAD: WHERE LOWER(email) = $1 won't use regular index on email
CREATE UNIQUE INDEX idx_users_email_lower
ON users (LOWER(email)); -- now LOWER(email) queries use this index
-- Composite index — column order matters
-- Serves: WHERE user_id = ? AND created_at > ?
-- Serves: WHERE user_id = ? (leftmost prefix)
-- Does NOT serve: WHERE created_at > ? (skips leftmost column)
CREATE INDEX idx_orders_user_date
ON orders (user_id, created_at DESC);
-- Covering index (includes extra columns to avoid table lookup)
CREATE INDEX idx_users_email_covering
ON users (email) INCLUDE (id, name);
-- GIN for JSONB queries
CREATE INDEX idx_products_metadata
ON products USING GIN (metadata);
-- Enables: WHERE metadata @> '{"category": "electronics"}'
-- Full-text search with pg_trgm (substring/fuzzy search)
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm
ON users USING GIN (name gin_trgm_ops);
-- Enables: WHERE name LIKE '%john%' or ILIKE Finding Slow Queries with pg_stat_statements
-- Enable (add to postgresql.conf then restart): -- shared_preload_libraries = 'pg_stat_statements' -- pg_stat_statements.track = all CREATE EXTENSION IF NOT EXISTS pg_stat_statements; -- Top 10 slowest queries by average execution time SELECT left(query, 100) AS query_snippet, calls, round(mean_exec_time::numeric, 2) AS avg_ms, round(total_exec_time::numeric, 2) AS total_ms, rows FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10; -- Top 10 by total time (most impactful to optimize) SELECT left(query, 100) AS query_snippet, calls, round(mean_exec_time::numeric, 2) AS avg_ms, round(total_exec_time::numeric / 1000, 2) AS total_sec FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; -- Reset stats before a new performance test SELECT pg_stat_statements_reset();
N+1 Query Fix with JSON Aggregation
-- BAD (N+1 pattern): 1 query for users + N queries for orders
-- SELECT * FROM users WHERE active = true;
-- For each user: SELECT * FROM orders WHERE user_id = $1;
-- GOOD: single query with JSON aggregation
SELECT
u.id,
u.name,
u.email,
COALESCE(json_agg(
json_build_object(
'id', o.id,
'amount', o.total_amount,
'created_at', o.created_at
) ORDER BY o.created_at DESC
) FILTER (WHERE o.id IS NOT NULL), '[]') AS orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.active = true
GROUP BY u.id, u.name, u.email;
-- Result: each user row includes a JSON array of their orders
-- One round-trip instead of N+1 Optimization Checklist
- Run ANALYZE after large data changes to update planner statistics
- Check for Seq Scan on large tables — add index on WHERE and JOIN columns
- Check estimated vs actual rows — large discrepancy means stale statistics
- Avoid functions on indexed columns — use functional indexes or rewrite predicates
- Use LIMIT for pagination — avoid OFFSET on large tables; use keyset pagination instead
- Index foreign keys — PostgreSQL does not auto-index FK columns; always add them manually
- Use connection pooling — PgBouncer or RDS Proxy prevents connection overhead from being the bottleneck
Frequently Asked Questions
How do I read a PostgreSQL EXPLAIN ANALYZE output?▼
EXPLAIN ANALYZE runs the query and shows the actual execution plan. Key metrics to read: 'Seq Scan' means full table scan — usually needs an index. 'rows' shows estimated vs actual rows (large discrepancy means stale statistics — run ANALYZE). 'cost' shows estimated I/O units (not milliseconds). 'actual time' shows real execution time in milliseconds. 'Buffers: hit' shows pages read from cache vs 'read' from disk. 'Nested Loop' with large row counts often indicates a missing index on the join condition.
When should I use a composite (multi-column) index?▼
Use a composite index when your WHERE clause frequently filters on multiple columns together. The column order matters: put the most selective column (highest cardinality) first, or put the equality columns before range columns. A composite index on (user_id, created_at) serves queries like WHERE user_id = 5 AND created_at > '2025-01-01' efficiently. It also serves queries that filter only on user_id (leftmost prefix). It does NOT efficiently serve queries on only created_at — a separate index is needed for that.
What is the difference between a B-tree, Hash, GIN, and GiST index?▼
B-tree (default) handles equality, range comparisons (<, >, BETWEEN), and ORDER BY — use for most cases. Hash indexes handle only equality comparisons and are faster for exact matches on high-cardinality columns. GIN (Generalized Inverted Index) is optimal for multi-value types: JSONB, arrays, and full-text search tsvector — use for @>, ?, and @@ operators. GiST (Generalized Search Tree) handles geometric types, range types, and full-text — more flexible than GIN but slower for pure text search. BRIN is for very large tables with naturally ordered data (timestamps on append-only tables).
Why is my query slow even though it has an index?▼
Common reasons an index is not used: (1) Function wrapping the indexed column — WHERE LOWER(email) = ... prevents index use; create a functional index instead. (2) LIKE with leading wildcard — WHERE name LIKE '%john%' can't use a B-tree index; use GIN with pg_trgm for substring search. (3) Implicit type cast — comparing an integer column to a string literal causes a cast that bypasses the index. (4) Low selectivity — if the index would return more than 5-15% of rows, the planner may prefer a seq scan. (5) Stale statistics — run ANALYZE to update planner statistics.
What is pg_stat_statements and how do I use it to find slow queries?▼
pg_stat_statements is a PostgreSQL extension that tracks execution statistics for every distinct query. Enable it by adding shared_preload_libraries = 'pg_stat_statements' to postgresql.conf and running CREATE EXTENSION pg_stat_statements. Query it with SELECT query, calls, mean_exec_time, total_exec_time FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 20 to find the slowest queries by average execution time. Reset statistics with SELECT pg_stat_statements_reset() before a performance test.
How do I avoid N+1 query problems in PostgreSQL?▼
N+1 occurs when code executes one query to fetch N records, then N additional queries to fetch related data. Fix with JOINs or subqueries that fetch everything in one round trip. For ORM-based apps: use eager loading (Prisma: include, Sequelize: include, ActiveRecord: includes). For complex cases, use lateral joins or JSON aggregation: SELECT u.*, json_agg(o.*) AS orders FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id returns each user with their orders array in a single query.
Related Products
on GumroadDeveloper Cheat Sheet Bundle
10 print-ready cheat sheets for the tools developers use every day: Git, Docker, Linux CLI, SQL, Regex, HTTP Status codes, VS Code shortcuts, Bash scripting, jq/JSON, and Markdown.
Buy on Gumroad →DevPlaybook Interview Vault
210+ real technical interview questions with model answers, asked at Google, Meta, Amazon, Microsoft, Stripe, and Shopify. Covers Frontend (55), Backend (55), System Design (50), and DSA (50). Available in English and Traditional Chinese.
Buy on Gumroad →TypeScript Quick Reference Toolkit
5 practical reference files for TypeScript developers: cheatsheet, TS vs JS comparison, 20 utility types with real examples, tsconfig best practices template, and 15 common mistakes guide. Print it. Keep it on your desk.
Buy on Gumroad →You Might Also Need
SQL Formatter & Beautifier
Format and beautify SQL queries online. Auto-indent SELECT, FROM, WHERE, JOIN clauses, uppercase keywords, and minify SQL to a single line. Supports MySQL, PostgreSQL, SQLite. Free, runs in browser —
Open free tool →
SQL Query Formatter
Format and prettify SQL queries online. Auto-indent SELECT, FROM, WHERE, JOIN clauses, uppercase or lowercase keywords, configurable indent size. Supports MySQL, PostgreSQL, SQLite. Runs in browser.
Open free tool →
SQL Query Formatter Pro
Advanced SQL formatter with MySQL, PostgreSQL, and SQLite dialect support. Syntax highlighting, configurable indent size and keyword case. Free, runs in your browser.
Open free tool →
More Free Tools
Related Articles
PostgreSQL Performance Tuning: Indexing Strategies Every Developer Should Know
Master PostgreSQL indexing in 2026: EXPLAIN ANALYZE, B-tree vs GIN vs GiST indexes, partial indexes, covering indexes, and real-world optimization patterns. With code examples and query plans.
SQLite vs PostgreSQL vs MySQL: Database Selection Guide 2026
A comprehensive technical comparison of SQLite, PostgreSQL, and MySQL for 2026 — covering ACID compliance, performance benchmarks, JSON support, replication, and which database to choose for your use case.
Database Indexing Strategies Every Developer Must Know
Master database indexing in 2026. Learn B-tree, hash, composite, partial, and covering indexes with practical SQL examples for PostgreSQL and MySQL.
PostgreSQL vs MySQL: Which to Choose in 2026?
Comprehensive comparison of PostgreSQL and MySQL in 2026. Performance, JSON support, replication, licensing, and a decision framework for choosing the right database for your project.
PostgreSQL vs MySQL 2026: Which Database Should You Choose?
A comprehensive comparison of PostgreSQL and MySQL in 2026. Deep dive into performance, JSON support, replication, ecosystem, and which database fits your use case.
Enjoying this tool? Get more like it.
Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.
Unlock AI-Powered Dev Tools
- ⚡ AI Code Review, Doc Generator & SQL Builder
- ⚡ All premium templates & early access
- ⚡ Member discounts on Gumroad products
$9/mo after trial · Cancel anytime
Want the full toolkit?
Get DevPlaybook Pro
Every template, guide, boilerplate, and automation script in one bundle. 13 premium products — grab them all at once and save big.
MIT licensed · Instant download · No subscription
See what's included → Browse all deals →