SQL query tuning

Guide agents with EXPLAIN, statistics, and access patterns to propose indexes and rewrites, and spell out what must be validated in production.

Classify issues first: full scans, bad cardinality estimates, implicit casts, OR disabling indexes, large sorts/temps; rank hypotheses and cite key plan operators.

Index advice should cover selectivity, left-prefix rules, and write amplification; composite column order should match real WHERE + ORDER BY combinations.

Do not hand-wave “just add cache”: if caching appears, document key design, invalidation, and consistency separately from SQL tuning conclusions.

  • Pagination: prefer keyset/seek over large OFFSET for deep pages.
  • JOINs: note driver choice and when NL/Hash/Merge tends to apply.
  • Validation: before/after timing or row counts with representative parameters.

Plan-reading workflow (ASCII)

Typical read order from statement to actionable hypotheses; operator names vary by engine but the layering is the same.

-- EXPLAIN ANALYZE real output example (PostgreSQL)
-- Slow query: full table scan
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 12345 AND status = 'pending';

-- Output without index:
-- Seq Scan on orders  (cost=0.00..4821.00 rows=3 width=128)
--                     (actual time=0.042..23.847 rows=3 loops=1)
--   Filter: ((user_id = 12345) AND (status = 'pending'))
--   Rows Removed by Filter: 98997
-- Planning Time: 0.124 ms
-- Execution Time: 23.923 ms    <- full scan of 99000 rows returning only 3
-- Buffers: shared hit=2257

-- After creating a composite index:
CREATE INDEX idx_orders_user_status ON orders (user_id, status);

-- Optimized output:
-- Index Scan using idx_orders_user_status on orders
--              (cost=0.42..12.43 rows=3 width=128)
--              (actual time=0.024..0.031 rows=3 loops=1)
--   Index Cond: ((user_id = 12345) AND (status = 'pending'))
-- Planning Time: 0.201 ms
-- Execution Time: 0.058 ms    <- down from 23ms to 0.06ms, 400x improvement
-- Buffers: shared hit=5

-- Key signals:
-- 1. Seq Scan + large Rows Removed by Filter -> missing index
-- 2. actual rows vs estimated rows diverge -> stale stats (run ANALYZE)
-- 3. Sort / Hash / Temp -> work_mem insufficient (adjust session-level for testing)
-- 4. Nested Loop with large driver table rows -> possible wrong join order
┌─────────────┐     ┌──────────────┐     ┌─────────────────┐
│ Slow log /  │────▶│ EXPLAIN      │────▶│ Find costliest   │
│ SLO breach  │     │ (ANALYZE)    │     │ subtree: rows…   │
└─────────────┘     └──────────────┘     └────────┬────────┘
                                                  │
                    ┌─────────────────────────────┼─────────────────────────────┐
                    ▼                             ▼                             ▼
            ┌───────────────┐            ┌───────────────┐            ┌───────────────┐
            │ Access path   │            │ Join order /  │            │ Materialize / │
            │ Seq vs Index  │            │ NL / Hash /   │            │ Sort / Agg    │
            └───────┬───────┘            └───────┬───────┘            └───────┬───────┘
                    │                             │                             │
                    └─────────────────────────────┴─────────────────────────────┘
                                                  │
                                                  ▼
                                        ┌──────────────────┐
                                        │ Check stats      │
                                        │ ndv / hist / corr│
                                        └────────┬─────────┘
                                                  │
                                                  ▼
                                        ┌──────────────────┐
                                        │ Index/rewrite    │
                                        │ + reproducible   │
                                        │ validation steps │
                                        └──────────────────┘
            

Index heuristics

Rules of thumb—always confirm against real plans and data skew.

-- Composite index column order: equality first, range next, sort last
-- Query pattern: WHERE user_id = ? AND created_at > ? ORDER BY created_at
-- Correct order (equality column first):
CREATE INDEX idx_orders_uid_created
    ON orders (user_id, created_at DESC);
-- user_id equality filter followed by created_at ordered for ORDER BY (avoids Sort node)

-- Wrong order (range column first):
-- CREATE INDEX idx_orders_created_uid ON orders (created_at, user_id);
-- Result: user_id cannot leverage index ordering after created_at range scan

-- Covering index: avoid bookmark lookups (table heap reads)
-- If SELECT only needs user_id, status, amount, INCLUDE extra columns
CREATE INDEX idx_orders_cover
    ON orders (user_id, status)
    INCLUDE (amount, created_at);
-- Effect: Index Only Scan, no heap read needed

-- Partial index: low-cardinality column + common filter predicate
CREATE INDEX idx_orders_pending
    ON orders (user_id)
    WHERE status = 'pending';
-- Useful when 99% of rows are 'completed' and only 1% are 'pending'
Case Lean toward Watch out
Many equality predicates Put high-selectivity columns first; = keys on the left of composites Range predicates often break index order for later columns
Sort + filter Try (filter prefix, ORDER BY cols…) Balance SELECT * bookmark lookups vs covering indexes
Multi-table JOIN Small intermediate as driver; index join keys Stale stats pick wrong drivers and algorithms
Low-selectivity columns Standalone index may help little Consider composite, partial indexes, or query rewrite
Write-heavy tables Keep index count modest Every write maintains every secondary index

N+1 queries

N+1: one outer query returns N rows, then one child query or lazy load per row—roughly 1+N round trips. Common with ORM lazy loading. Distinguish “one slow statement” from “many light statements stacking”; mitigations include JOIN/IN batching, explicit eager loads, DataLoader-style batching, or a single aggregate subquery.
-- Anti-pattern: N+1 (pseudocode + SQL)
-- Query 1: SELECT id FROM users WHERE active = true  -> 1000 rows
-- Then for each user: SELECT * FROM orders WHERE user_id = ?  -> 1000 times
-- Total: 1001 queries

-- Fix: JOIN to fetch in one query
SELECT u.id, u.name, o.id AS order_id, o.amount, o.status
FROM   users u
LEFT   JOIN orders o ON o.user_id = u.id
WHERE  u.active = true;

-- Fix: IN batch prefetch (DataLoader pattern)
SELECT * FROM orders
WHERE  user_id = ANY(ARRAY[1,2,3,...,1000]);  -- single batch

-- IN vs EXISTS performance comparison
-- IN (efficient when subquery result set is small):
SELECT * FROM orders
WHERE  user_id IN (SELECT id FROM users WHERE plan = 'premium');

-- EXISTS (efficient when outer table is large and subquery short-circuits):
SELECT * FROM orders o
WHERE  EXISTS (
    SELECT 1 FROM users u
    WHERE  u.id = o.user_id AND u.plan = 'premium'
);
-- Rule: small subquery result -> IN; large outer table + indexed subquery -> EXISTS

-- Correlated subquery anti-pattern (executes once per row):
SELECT u.id, (SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total
FROM   users u;

-- Fix: rewrite as JOIN + GROUP BY (single scan):
SELECT u.id, COALESCE(SUM(o.amount), 0) AS total
FROM   users u
LEFT   JOIN orders o ON o.user_id = u.id
GROUP  BY u.id;
-- PgBouncer connection pool key parameters (pgbouncer.ini)
[databases]
payments = host=127.0.0.1 port=5432 dbname=payments

[pgbouncer]
pool_mode = transaction          ; transaction/session/statement
max_client_conn = 1000           ; max client connections
default_pool_size = 20           ; pool size per (db, user) pair
min_pool_size = 5                ; minimum persistent connections (avoid cold start)
reserve_pool_size = 5            ; emergency reserve connections
reserve_pool_timeout = 5         ; activate reserve after timeout (seconds)
max_db_connections = 100         ; total backend connections per database
server_idle_timeout = 600        ; close idle backend connections (seconds)
client_idle_timeout = 0          ; 0 = disable client timeout
auth_type = md5
logfile = /var/log/pgbouncer/pgbouncer.log
pidfile = /var/run/pgbouncer/pgbouncer.pid

; Sizing: QPS=500, p95=50ms -> concurrency = 500 * 0.05 = 25
; Set default_pool_size to 25-30, with ~20% headroom

SQL keywords & rough clause order

Paste SQL below: common keywords highlight in sync, plus a rough relative clause order for reading only—not a real cost model.

Keyword highlight preview

---
name: sql-query-tuning
description: Analyze slow SQL with EXPLAIN, index DDL, and rewrite drafts
tags: [sql, postgresql, performance, indexing]
---
# Diagnosis Steps
- EXPLAIN (ANALYZE, BUFFERS) to get the real plan
- Identify: Seq Scan + large Rows Removed -> missing index
- Identify: actual rows vs estimate diverge -> run ANALYZE to refresh stats
- Identify: Sort / Hash / Temp -> insufficient work_mem (test with session-level adjustment)

# Index Strategy
- Equality columns first, range columns next, ORDER BY last (same direction)
- Covering index INCLUDE reduces table heap reads; partial index WHERE filters low-selectivity
- Use CREATE INDEX CONCURRENTLY for large tables; never inside a transaction block

# Common Slow Query Fixes
- N+1: LEFT JOIN ... GROUP BY or IN(SELECT id ...) for batch prefetch
- Full scan: build index or rewrite WHERE to avoid wrapping index columns in functions
- Correlated subquery: rewrite as JOIN + GROUP BY (single scan)
- IN vs EXISTS: small subquery result -> IN; large outer with indexed subquery -> EXISTS

# Connection Pooling
- PgBouncer pool_mode=transaction; default_pool_size = QPS * p95_sec * 1.2
- Align max_client_conn with app instances * thread count

# Validation
- Compare before/after Execution Time; use representative parameters + ANALYZE before testing in production

All skills More skills