SQL query optimization patterns including EXPLAIN plan analysis, index strategies, query rewriting, and N+1 query prevention. Use when optimizing slow database queries, analyzing query performance, designing indexes, or debugging database bottlenecks. Works with PostgreSQL, MySQL, SQLite, and other SQL databases. Typical improvements: 10x-1000x query speedup.
Add this skill
npx mdskills install applied-artificial-intelligence/sql-optimizationComprehensive reference guide with excellent EXPLAIN analysis, index strategies, and concrete optimization patterns across databases
Comprehensive guide to optimizing SQL queries for performance, including EXPLAIN plan analysis, index design strategies, query rewriting patterns, and N+1 query detection. Works across PostgreSQL, MySQL, and SQLite.
When to use this skill:
Common triggers:
Typical improvements:
EXPLAIN ANALYZE
SELECT u.name, p.title
FROM users u
JOIN posts p ON u.id = p.user_id
WHERE u.created_at > '2024-01-01'
ORDER BY p.created_at DESC
LIMIT 10;
Key Metrics to Watch:
Seq Scan on users u (cost=0.00..1234.00 rows=1000 width=50)
(actual time=0.123..45.678 rows=950 loops=1)
Filter: (created_at > '2024-01-01'::date)
Rows Removed by Filter: 50000
Planning Time: 0.234 ms
Execution Time: 3456.789 ms
Problems:
Index Scan using users_created_at_idx on users u
(cost=0.29..123.45 rows=950 width=50)
(actual time=0.012..3.456 rows=950 loops=1)
Index Cond: (created_at > '2024-01-01'::date)
Planning Time: 0.123 ms
Execution Time: 4.567 ms
Improvements:
✅ Add index when:
❌ Don't add index when:
-- ❌ Doesn't use index (skips first column) WHERE status = 'pending' WHERE created_at > '2024-01-01'
### Partial Indexes (Filtered)
**Index only subset of rows** (smaller, faster):
```sql
-- Only index active users
CREATE INDEX idx_users_active_email
ON users(email)
WHERE status = 'active';
-- Query that uses partial index
SELECT * FROM users
WHERE email = 'user@example.com'
AND status = 'active'; -- Must include filter condition!
Benefits:
Include columns in index to avoid table access:
-- Postgres: INCLUDE clause
CREATE INDEX idx_users_email_covering
ON users(email)
INCLUDE (name, created_at);
-- Query doesn't need to access table (all data in index)
SELECT name, created_at FROM users WHERE email = 'user@example.com';
Benefit: Index-Only Scan (even faster than Index Scan)
❌ Bad (fetches all columns):
SELECT * FROM users WHERE id = 123;
✅ Good (fetches only needed columns):
SELECT id, name, email FROM users WHERE id = 123;
Impact:
❌ Bad (1 query for posts + N queries for users):
# 1 query
posts = db.execute("SELECT * FROM posts LIMIT 10")
# N queries (10 separate queries!)
for post in posts:
user = db.execute("SELECT * FROM users WHERE id = ?", post.user_id)
print(f"{user.name}: {post.title}")
✅ Good (2 queries total):
# 1 query for posts
posts = db.execute("SELECT * FROM posts LIMIT 10")
# 1 query for all users
user_ids = [p.user_id for p in posts]
users = db.execute("SELECT * FROM users WHERE id IN (?)", user_ids)
users_by_id = {u.id: u for u in users}
# No additional queries
for post in posts:
user = users_by_id[post.user_id]
print(f"{user.name}: {post.title}")
Even Better (1 query with JOIN):
SELECT u.name, p.title
FROM posts p
JOIN users u ON p.user_id = u.id
LIMIT 10;
❌ Bad (index not used due to implicit conversion):
-- user_id is INTEGER, but '123' is string
SELECT * FROM posts WHERE user_id = '123'; -- Seq Scan!
✅ Good (explicit type, index used):
SELECT * FROM posts WHERE user_id = 123; -- Index Scan
❌ Bad (can't use index):
-- Function prevents index usage
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
✅ Good (use functional index):
-- Create index on function result
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
-- Now query can use index
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
✅ Even Better (store lowercase, regular index):
-- Store email in lowercase
CREATE INDEX idx_users_email ON users(email);
-- Query with lowercase value
SELECT * FROM users WHERE email = LOWER('user@example.com');
❌ Bad (often does Seq Scan):
SELECT * FROM users
WHERE email = 'user@example.com'
OR username = 'johndoe';
✅ Good (use UNION, allows index usage):
SELECT * FROM users WHERE email = 'user@example.com'
UNION
SELECT * FROM users WHERE username = 'johndoe';
❌ Bad (very slow, especially with large subquery):
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM blocked_users);
✅ Good (use LEFT JOIN with NULL check):
SELECT u.*
FROM users u
LEFT JOIN blocked_users b ON u.id = b.user_id
WHERE b.user_id IS NULL;
Database optimizes join order, but you can help:
Small table first, large table second:
-- Good: Join small table (categories: 10 rows) to large table (products: 10M rows)
SELECT p.*
FROM categories c
JOIN products p ON c.id = p.category_id
WHERE c.name = 'Electronics';
❌ Bad (produces rows1 × rows2 × rows3):
SELECT *
FROM users, posts, comments; -- 100 × 10000 × 50000 = 50 billion rows!
✅ Good (proper JOIN conditions):
SELECT *
FROM users u
JOIN posts p ON u.id = p.user_id
JOIN comments c ON p.id = c.post_id;
Scenario: Find users who have at least one post
❌ Slower (IN loads all post IDs):
SELECT * FROM users
WHERE id IN (SELECT user_id FROM posts);
✅ Faster (EXISTS stops at first match):
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM posts p WHERE p.user_id = u.id);
Use JOIN when you need data from both tables:
SELECT u.name, p.title
FROM users u
JOIN posts p ON u.id = p.user_id;
Use Subquery when you only need filtering:
SELECT * FROM users
WHERE id IN (SELECT DISTINCT user_id FROM posts);
Problem: COUNT(*) on large tables is slow
❌ Slow (counts all rows):
SELECT COUNT(*) FROM orders; -- Full table scan on 10M rows
✅ Faster (approximate count from statistics):
-- PostgreSQL: Use statistics (fast but approximate)
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';
✅ Also Good (exact count with index):
-- If you have an index, COUNT can use index-only scan
CREATE INDEX idx_orders_id ON orders(id);
SELECT COUNT(*) FROM orders; -- Index-only scan
❌ Bad (groups after fetching all data):
SELECT user_id, COUNT(*)
FROM posts
GROUP BY user_id;
✅ Good (index helps grouping):
-- Create index on grouped column
CREATE INDEX idx_posts_user_id ON posts(user_id);
SELECT user_id, COUNT(*)
FROM posts
GROUP BY user_id; -- Can use index for grouping
❌ Bad (filters after grouping):
SELECT user_id, COUNT(*) as post_count
FROM posts
GROUP BY user_id
HAVING user_id > 1000; -- Filters after grouping all users
✅ Good (filters before grouping):
SELECT user_id, COUNT(*) as post_count
FROM posts
WHERE user_id > 1000 -- Filters before grouping (can use index)
GROUP BY user_id;
❌ Bad (OFFSET scans all skipped rows):
-- Page 1000 (skips 50,000 rows, then returns 50)
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 50 OFFSET 50000; -- Scans 50,050 rows!
✅ Good (keyset pagination with WHERE):
-- Save last seen ID from previous page
SELECT * FROM posts
WHERE created_at 0
ORDER BY seq_tup_read DESC
LIMIT 25;
-- Indexes that are never used
SELECT
schemaname,
tablename,
indexname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%_pkey' -- Exclude primary keys
ORDER BY pg_relation_size(indexrelid) DESC;
-- Postgres: Check for bloated indexes
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- Rebuild bloated indexes
REINDEX INDEX index_name;
-- Create composite index
CREATE INDEX idx_posts_created_user ON posts(created_at DESC, user_id);
-- Query uses index efficiently
SELECT * FROM posts
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
-- Create GIN index for full-text search (Postgres)
CREATE INDEX idx_posts_search ON posts
USING gin(to_tsvector('english', title || ' ' || content));
-- Fast full-text search
SELECT * FROM posts
WHERE to_tsvector('english', title || ' ' || content) @@
to_tsquery('english', 'database & optimization');
-- Create index on range column
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- Query uses index
SELECT * FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY created_at;
-- Create composite index
CREATE INDEX idx_posts_category_user ON posts(category_id, user_id);
-- Query uses index
SELECT DISTINCT category_id
FROM posts
WHERE user_id = 123;
Diagnosis:
Index Strategy:
Query Rewriting:
Pagination:
Monitoring:
PostgreSQL:
MySQL:
Tools:
Best experience: Claude Code
/plugin marketplace add applied-artificial-intelligence/sql-optimizationThen /plugin menu → select skill → restart. Use /skill-name:init for first-time setup.
Other platforms
Install via CLI
npx mdskills install applied-artificial-intelligence/sql-optimizationSQL Optimization is a free, open-source AI agent skill. SQL query optimization patterns including EXPLAIN plan analysis, index strategies, query rewriting, and N+1 query prevention. Use when optimizing slow database queries, analyzing query performance, designing indexes, or debugging database bottlenecks. Works with PostgreSQL, MySQL, SQLite, and other SQL databases. Typical improvements: 10x-1000x query speedup.
Install SQL Optimization with a single command:
npx mdskills install applied-artificial-intelligence/sql-optimizationThis downloads the skill files into your project and your AI agent picks them up automatically.
SQL Optimization works with Claude Code, Claude Desktop, Cursor, Vscode Copilot, Windsurf, Continue Dev, Codex, Gemini Cli, Amp, Roo Code, Goose, Opencode, Trae, Qodo, Command Code. Skills use the open SKILL.md format which is compatible with any AI coding agent that reads markdown instructions.