con un clic
database-postgres-query-optimization
Debugging slow queries in PostgreSQL
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Menú
Debugging slow queries in PostgreSQL
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Basado en la clasificación ocupacional SOC
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
| 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)