Query debugging and troubleshooting techniques

Maria Garcia Feb 2026
2 tabs
-- Basic query debugging with EXPLAIN
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';

-- Output shows query plan:
-- Seq Scan on users (cost=0.00..25.00 rows=1 width=100)
--   Filter: (email = 'test@example.com'::text)

-- EXPLAIN ANALYZE: Actually executes and shows real timing
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'test@example.com';

-- Output includes actual times:
-- Seq Scan on users (cost=0.00..25.00 rows=1 width=100)
--   (actual time=0.015..0.234 rows=1 loops=1)
--   Filter: (email = 'test@example.com'::text)
-- Planning Time: 0.123 ms
-- Execution Time: 0.456 ms

-- EXPLAIN with all options
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, COSTS, TIMING)
SELECT u.*, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id
HAVING COUNT(o.id) > 5;

-- BUFFERS shows cache hits/misses:
-- Shared Buffers: hit=245 read=12
-- (hit=cached, read=from disk)

-- Understanding query costs
-- cost=0.00..25.00: startup cost..total cost
-- rows=1: estimated rows
-- width=100: average row size in bytes

-- Identify missing indexes
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123;

-- If you see "Seq Scan" on large table, add index:
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- Re-run to verify
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123;
-- Should now show: Index Scan using idx_orders_user_id

-- Check if index is being used
SELECT
  schemaname,
  tablename,
  indexname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexname = 'idx_orders_user_id';

-- idx_scan=0 means index never used

-- Find unused indexes
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 indexname NOT LIKE '%pkey'
ORDER BY pg_relation_size(indexrelid) DESC;

-- Debug slow queries with pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Slowest queries by total time
SELECT
  calls,
  total_exec_time / 1000 AS total_seconds,
  mean_exec_time AS avg_ms,
  max_exec_time AS max_ms,
  stddev_exec_time AS stddev_ms,
  rows,
  shared_blks_hit,
  shared_blks_read,
  query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat%'
ORDER BY total_exec_time DESC
LIMIT 20;

-- Queries with most cache misses
SELECT
  calls,
  shared_blks_read,
  shared_blks_hit,
  shared_blks_read::FLOAT / NULLIF(shared_blks_hit + shared_blks_read, 0) AS miss_ratio,
  query
FROM pg_stat_statements
WHERE shared_blks_read > 0
ORDER BY miss_ratio DESC
LIMIT 20;

-- Debug blocking queries
SELECT
  blocked.pid AS blocked_pid,
  blocked.usename AS blocked_user,
  blocking.pid AS blocking_pid,
  blocking.usename AS blocking_user,
  blocked.query AS blocked_query,
  blocking.query AS blocking_query,
  blocked.wait_event_type,
  blocked.wait_event
FROM pg_stat_activity blocked
JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON
  blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
  AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
  AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
  AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
  AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
  AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
  AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
  AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

-- Kill blocking query (use with caution)
-- SELECT pg_cancel_backend(blocking_pid);
-- SELECT pg_terminate_backend(blocking_pid);

-- Debug transaction locks
SELECT
  l.locktype,
  l.mode,
  l.granted,
  a.pid,
  a.usename,
  a.application_name,
  a.state,
  NOW() - a.xact_start AS transaction_age,
  a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.locktype = 'transactionid'
ORDER BY transaction_age DESC;

-- Find long-running transactions
SELECT
  pid,
  usename,
  application_name,
  state,
  NOW() - xact_start AS transaction_duration,
  query
FROM pg_stat_activity
WHERE state != 'idle'
  AND xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;

-- Enable auto_explain for automatic slow query logging
-- In postgresql.conf:
/*
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '1s'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_timing = on
auto_explain.log_triggers = on
auto_explain.log_verbose = on
*/

-- Then check PostgreSQL logs for EXPLAIN output

-- Debug parameter sniffing issues
-- Problem: First execution creates plan for specific parameter
SET plan_cache_mode = 'force_custom_plan';

PREPARE user_orders (INT) AS
SELECT * FROM orders WHERE user_id = $1;

-- Each execution gets optimized plan for actual parameter
EXECUTE user_orders(123);
EXECUTE user_orders(456);

-- View prepared statement plans
SELECT * FROM pg_prepared_statements;
2 files · sql Explain with highlit

Query debugging identifies performance and correctness issues. I use EXPLAIN ANALYZE to understand execution plans. Slow query logs reveal problematic queries. pgstatstatements tracks query statistics. Understanding sequential scans vs index scans helps optimize. Checking locks identifies blocking queries. Query cost estimates guide optimization. Analyzing buffer hits shows cache effectiveness. Verbose output reveals detailed execution. Auto_explain extension logs slow queries automatically. Proper debugging prevents production issues. Essential for maintaining database health, resolving performance problems.