with one click
database-postgres-query-optimization
Debugging slow queries in PostgreSQL
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
Debugging slow queries in PostgreSQL
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
| name | database-postgres-query-optimization |
| description | Debugging slow queries in PostgreSQL |
Scope: Query analysis, EXPLAIN plans, index strategies, query rewriting Lines: ~350 Last Updated: 2025-10-18
Activate this skill when:
The planner's goal: find the lowest-cost plan.
PostgreSQL uses a cost-based optimizer:
-- Cost units are arbitrary (not milliseconds)
-- Lower cost = better plan (usually)
Seq Scan on users (cost=0.00..1234.56 rows=10000 width=64)
Index Scan using idx_users_email (cost=0.29..8.31 rows=1 width=64)
EXPLAIN SELECT * FROM users WHERE email = 'user@example.com';
Output shows the planned execution (no actual execution).
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user@example.com';
Output shows planned + actual execution with real timing.
CRITICAL: EXPLAIN ANALYZE actually runs the query (including writes!). Use with caution on production.
Seq Scan on users (cost=0.00..1234.56 rows=10000 width=64) (actual time=0.012..12.345 rows=1 loops=1)
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PLANNED cost/rows ACTUAL time/rows/loops
Red flags:
actual rows >> estimated rows - Statistics out of dateactual time >> expected - I/O bottleneck or missing indexSeq Scan on large tables - Usually needs indexloops > 1 on expensive operations - Nested loop inefficiencySeq Scan on orders (cost=0.00..15234.56 rows=500000 width=128)
When it happens:
Good for:
Bad for:
Index Scan using idx_orders_user_id on orders (cost=0.42..8.44 rows=1 width=128)
Index Cond: (user_id = 12345)
How it works:
Good for:
=, IN)Cost factors:
Index Only Scan using idx_orders_user_created on orders (cost=0.42..4.44 rows=1 width=8)
Index Cond: (user_id = 12345)
Heap Fetches: 0
How it works:
Requirements:
BEST PERFORMANCE: No random I/O to heap.
-- Create covering index for this query
CREATE INDEX idx_orders_user_created ON orders(user_id) INCLUDE (created_at);
-- Query can now use Index Only Scan
SELECT created_at FROM orders WHERE user_id = 12345;
Bitmap Heap Scan on orders (cost=123.45..5678.90 rows=5000 width=128)
Recheck Cond: (status = 'pending' OR status = 'processing')
-> BitmapOr (cost=123.45..123.45 rows=5000 width=0)
-> Bitmap Index Scan on idx_orders_status (cost=0.00..61.00 rows=2500 width=0)
Index Cond: (status = 'pending')
-> Bitmap Index Scan on idx_orders_status (cost=0.00..61.00 rows=2500 width=0)
Index Cond: (status = 'processing')
How it works:
Good for:
OR conditions)Better than:
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_composite ON orders(user_id, created_at);
Good for:
email = 'foo@example.com'created_at > '2024-01-01'ORDER BY created_atemail LIKE 'foo%' (NOT LIKE '%foo')Multi-column indexes (composite):
(user_id, created_at) can be used for:
user_id = Xuser_id = X AND created_at > Ycreated_at > Y aloneCREATE INDEX idx_users_email_hash ON users USING HASH (email);
Good for:
email = 'foo@example.com'Cannot:
Usually not needed - B-tree handles equality well.
CREATE INDEX idx_locations_geom ON locations USING GIST (geom);
CREATE INDEX idx_documents_fts ON documents USING GIST (to_tsvector('english', content));
Good for:
CREATE INDEX idx_documents_fts ON documents USING GIN (to_tsvector('english', content));
CREATE INDEX idx_users_tags ON users USING GIN (tags); -- array column
CREATE INDEX idx_metadata_json ON events USING GIN (metadata); -- jsonb column
Good for:
metadata @> '{"status": "active"}'tags @> ARRAY['postgres']Trade-offs:
CREATE INDEX idx_logs_created_brin ON logs USING BRIN (created_at);
Good for:
Extremely small index (1000x smaller than B-tree).
Trade-off: Less precise, may scan extra blocks.
Start: Do I need an index?
│
├─ Table < 1000 rows? → NO (Seq Scan is fine)
├─ Query reads >10% of table? → MAYBE (test both)
└─ Query is selective? → YES
│
├─ What type of query?
│ ├─ Equality (=, IN) → B-tree
│ ├─ Range (<, >, BETWEEN) → B-tree
│ ├─ Sorting (ORDER BY) → B-tree on sort columns
│ ├─ Full-text search → GIN or GiST
│ ├─ JSONB queries → GIN
│ ├─ Geometric queries → GiST
│ ├─ Time-series append-only → BRIN
│ └─ Array containment → GIN
│
├─ Multiple columns in WHERE?
│ ├─ Always used together → Composite index (user_id, created_at)
│ ├─ Used independently → Separate indexes (or partial indexes)
│ └─ OR conditions → Bitmap scan or separate indexes
│
└─ Can I cover the query? → Add INCLUDE columns for Index Only Scan
-- Anti-pattern: Load users, then loop and query orders for each
SELECT * FROM users;
-- In application loop:
-- SELECT * FROM orders WHERE user_id = ?
-- Solution: JOIN or batch query
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
-- Anti-pattern
SELECT * FROM orders WHERE user_id = 123;
-- Better: Select only needed columns
SELECT id, total, created_at FROM orders WHERE user_id = 123;
Why it matters:
-- Anti-pattern: Function on indexed column
SELECT * FROM users WHERE LOWER(email) = 'foo@example.com';
-- Solution: Functional index
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- Or store email in lowercase always
-- Anti-pattern: Wildcard at start
SELECT * FROM users WHERE email LIKE '%@example.com';
-- Solution: Full-text search or trigram index
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_email_trgm ON users USING GIN (email gin_trgm_ops);
-- Anti-pattern: DISTINCT when not needed
SELECT DISTINCT user_id FROM orders WHERE status = 'pending';
-- Better if user_id is already unique per status:
SELECT user_id FROM orders WHERE status = 'pending';
-- Anti-pattern: OR across tables
SELECT * FROM orders WHERE user_id = 123 OR vendor_id = 456;
-- Better: UNION
SELECT * FROM orders WHERE user_id = 123
UNION
SELECT * FROM orders WHERE vendor_id = 456;
-- Slower: Correlated subquery
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100);
-- Faster: JOIN with DISTINCT
SELECT DISTINCT u.* FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE o.total > 100;
-- Or EXISTS (often better for large datasets)
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 100);
-- When you only need to check existence:
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- NOT:
SELECT DISTINCT u.* FROM users u INNER JOIN orders o ON o.user_id = u.id;
EXISTS is faster when you don't need order data, just existence check.
-- Common query:
SELECT * FROM orders WHERE status = 'pending';
-- Create partial index
CREATE INDEX idx_orders_pending ON orders(created_at) WHERE status = 'pending';
Benefits:
-- Slow query run frequently:
SELECT user_id, COUNT(*), SUM(total) FROM orders GROUP BY user_id;
-- Create materialized view
CREATE MATERIALIZED VIEW user_order_stats AS
SELECT user_id, COUNT(*) as order_count, SUM(total) as total_spent
FROM orders
GROUP BY user_id;
CREATE UNIQUE INDEX idx_user_order_stats_user ON user_order_stats(user_id);
-- Refresh periodically
REFRESH MATERIALIZED VIEW CONCURRENTLY user_order_stats;
PostgreSQL uses table statistics to estimate row counts and plan queries.
-- Outdated statistics cause bad plans
-- Fix: Analyze the table
ANALYZE users;
ANALYZE orders;
-- Auto-vacuum should handle this, but manual ANALYZE helps after bulk changes
Indexes can become bloated over time.
-- Check index bloat
SELECT schemaname, tablename, indexname,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- Rebuild index
REINDEX INDEX idx_users_email;
REINDEX TABLE users; -- All indexes on table
-- Or recreate index (allows CONCURRENTLY, less locking)
CREATE INDEX CONCURRENTLY idx_users_email_new ON users(email);
DROP INDEX CONCURRENTLY idx_users_email;
ALTER INDEX idx_users_email_new RENAME TO idx_users_email;
-- Find unused indexes
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
-- Drop unused indexes to improve write performance
DROP INDEX idx_never_used;
-- Enable slow query logging in postgresql.conf
log_min_duration_statement = 1000 -- Log queries > 1 second
-- Or use pg_stat_statements extension
CREATE EXTENSION pg_stat_statements;
SELECT query, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
EXPLAIN ANALYZE <your slow query>;
Look for:
-- When was table last analyzed?
SELECT schemaname, tablename, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE tablename = 'orders';
-- If stale, analyze
ANALYZE orders;
-- Based on WHERE, JOIN, ORDER BY clauses
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
EXPLAIN ANALYZE <your slow query>;
Compare before/after:
CRITICAL: Optimizer chooses plans based on table size.
Test with realistic data volume.
EXPLAIN SELECT ...; -- Plan only, no execution
EXPLAIN ANALYZE SELECT ...; -- Plan + actual execution
EXPLAIN (ANALYZE, BUFFERS) SELECT ...; -- Include I/O stats
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...; -- Full details
-- B-tree (default)
CREATE INDEX idx_name ON table(column);
-- Composite
CREATE INDEX idx_name ON table(col1, col2);
-- Covering (Index Only Scan)
CREATE INDEX idx_name ON table(col1) INCLUDE (col2, col3);
-- Partial
CREATE INDEX idx_name ON table(col) WHERE condition;
-- Functional
CREATE INDEX idx_name ON table(LOWER(email));
-- Concurrent (no table lock)
CREATE INDEX CONCURRENTLY idx_name ON table(column);
-- Other types
CREATE INDEX idx_name ON table USING HASH (column);
CREATE INDEX idx_name ON table USING GIN (jsonb_column);
CREATE INDEX idx_name ON table USING GIST (geometry_column);
CREATE INDEX idx_name ON table USING BRIN (timestamp_column);
Query Performance Issues:
[ ] Run EXPLAIN ANALYZE to see actual execution
[ ] Check for Seq Scan on large tables
[ ] Verify statistics are current (ANALYZE table)
[ ] Look for missing indexes on WHERE/JOIN columns
[ ] Check if index is being used (Index Cond vs Filter)
[ ] Consider composite index for multi-column queries
[ ] Use covering index (INCLUDE) for Index Only Scan
[ ] Check for N+1 queries (use JOIN or batch)
[ ] Verify query is sargable (no functions on indexed columns)
[ ] Consider partial index for filtered queries
[ ] Test with production-like data volume
[ ] Monitor index usage (drop unused indexes)
This skill includes Level 3 Resources (executable tools, reference materials, examples):
Located in ./postgres-query-optimization/resources/scripts/:
See scripts/README.md for usage examples.
Quick Start:
# Analyze EXPLAIN output
python postgres-query-optimization/resources/scripts/analyze_query.py --explain-file explain.txt
# Get index recommendations
python postgres-query-optimization/resources/scripts/suggest_indexes.py --query "SELECT * FROM orders WHERE user_id = 123"
# Benchmark query
./postgres-query-optimization/resources/scripts/benchmark_queries.sh --query "SELECT ..." --iterations 20
# Start test environment
cd postgres-query-optimization/resources/examples/docker && docker-compose up -d
postgres-migrations.md - Safe schema changes, adding indexes without downtimepostgres-schema-design.md - Table design affects query performanceorm-patterns.md - ORM-specific N+1 prevention, eager loadingdatabase-connection-pooling.md - Connection limits affect query concurrencydatabase-selection.md - When to use Postgres vs other databases❌ Running EXPLAIN ANALYZE on writes in production - It executes the query (including DELETE!) ✅ Use EXPLAIN (no ANALYZE) for writes, or test on staging
❌ Creating too many indexes - Slows down writes, wastes space ✅ Monitor index usage, drop unused indexes
❌ Ignoring statistics - Planner makes bad decisions with stale stats ✅ Run ANALYZE after bulk changes, ensure auto-vacuum is working
❌ Not testing with realistic data volume - Plans change with table size ✅ Test on production-like dataset
❌ Using DISTINCT when not needed - Adds expensive sort/dedup ✅ Only use when actually needed
❌ Assuming index always helps - Small tables are faster with Seq Scan ✅ Test before/after, trust the planner for small tables
Last Updated: 2025-10-27 Format Version: 1.0 (Atomic)
Index of Build Systems Skills
Coordination patterns for distributed dataflow systems including barriers, epochs, and distributed snapshots
Windowing, sessionization, time-series aggregation, and late data handling for streaming systems
Comprehensive guide to GNU Debugger (GDB) for debugging C/C++/Rust programs. Covers breakpoints, stack traces, variable inspection, TUI mode, .gdbinit customization, Python scripting, remote debugging, and core file analysis.
Paxos consensus algorithm including Basic Paxos, Multi-Paxos, roles, phases, and practical implementations
Gossip protocols for disseminating information, failure detection, and eventual consistency in large-scale distributed systems