Engineering Series
This post is part of the Distributed Database Systems: A Senior Engineer’s Guide series.
Debugging Slow Database Queries: A Systematic Approach
Learn how to identify and fix slow database queries using query plans, indexes, and optimization techniques from real production scenarios.
Part of the Pillar: Distributed Systems Failure Modes. This post covers systematic query optimization techniques. For architectural foundations, start with Why HTAP Systems Fail Quietly.
Slow queries are inevitable at scale. Here's my framework for debugging them efficiently, based on years of supporting production databases.
The 4-Step Framework
1. Identify the Bottleneck
Don't assume it's the database! Check:
- Application code: Is the query being called in a loop?
- Network: High latency between app and database?
- Database: Is the query itself slow?
bash# Quick network latency check
ping your-database-host.com
# Expected: < 5ms for same region
# Warning: > 50ms suggests network issues
2. Capture the Query Plan
The query plan shows how the database executes your query.
sql-- PostgreSQL / AWS RDS
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.order_id) as order_count
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE u.created_at > '2025-01-01'
GROUP BY u.user_id;
Key indicators:
Seq Scanon large tables → Missing indexNested Loopwith high row counts → Join inefficiencySortoperation → Consider covering indexes
3. Fix Access Patterns
Common fixes:
Add Missing Indexes
sql-- Before: Sequential scan on 10M rows
-- After: Index scan on 1K rows
CREATE INDEX idx_users_created_at ON users(created_at);
Optimize Joins
sql-- Bad: Large intermediate result set
SELECT *
FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.created_at > '2025-01-01';
-- Good: Filter first, then join
SELECT *
FROM (
SELECT * FROM orders WHERE created_at > '2025-01-01'
) o
JOIN products p ON o.product_id = p.product_id;
Use Covering Indexes
sql-- Query needs: user_id, email, created_at
CREATE INDEX idx_users_covering ON users(user_id, email, created_at);
-- Database can satisfy query entirely from index (no table lookup)
4. Validate Under Load
Test the fix in a staging environment with realistic data volume:
bash# Before optimization
ab -n 1000 -c 10 http://api.example.com/users
# After optimization
ab -n 1000 -c 10 http://api.example.com/users
Real-World Case Study
Problem
Dashboard query taking 45 seconds during peak hours:
sqlSELECT DATE(created_at) as day, COUNT(*) as orders
FROM orders
WHERE status = 'completed'
GROUP BY DATE(created_at)
ORDER BY day DESC
LIMIT 30;
Root Cause
- No index on
status DATE()function prevents index usage- Full table scan on 100M+ rows
Solution
sql-- Add functional index
CREATE INDEX idx_orders_completed_date
ON orders(DATE(created_at))
WHERE status = 'completed';
-- Or better: Store computed column
ALTER TABLE orders ADD COLUMN created_date DATE
GENERATED ALWAYS AS (DATE(created_at)) STORED;
CREATE INDEX idx_orders_status_date ON orders(status, created_date);
Result: Query time reduced from 45s → 120ms
Advanced Techniques
Partition Large Tables
sql-- Partition by month for time-series data
CREATE TABLE orders (
order_id BIGINT,
created_at TIMESTAMP,
...
) PARTITION BY RANGE (created_at);
-- Create partitions
CREATE TABLE orders_2025_01 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
Use Materialized Views
sql-- Pre-compute expensive aggregations
CREATE MATERIALIZED VIEW daily_sales AS
SELECT DATE(created_at) as day, SUM(total) as revenue
FROM orders
GROUP BY DATE(created_at);
-- Refresh hourly
REFRESH MATERIALIZED VIEW daily_sales;
Query Rewriting
sql-- Slow: DISTINCT on large table
SELECT DISTINCT user_id FROM orders;
-- Fast: Use EXISTS instead
SELECT user_id FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);
Monitoring Checklist
Set up alerts for:
- Queries taking > 1 second
- High CPU usage (> 80%)
- Lock waits
- Index bloat
- Replication lag
python# Example monitoring query
def identify_slow_queries():
return db.execute("""
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 1000
ORDER BY mean_exec_time DESC
LIMIT 10
""")
Takeaways
- Always check the query plan before optimizing
- Index strategically, not excessively
- Test with production-like data volume
- Monitor in production to catch regressions
Related Resources
- Getting Started with Distributed Databases - Foundational distributed database concepts
- How Distributed SQL Execution Engines Really Work - How distributed query execution works
- Why HTAP Systems Fail Quietly (And How to Notice Early) - Production failure modes
Want to dive deeper? Check out my post on Why HTAP Systems Fail Quietly.

About the Author
Shahid Moosa is a Cloud Database Support Engineer specializing in distributed systems, AWS, and SingleStore. He helps teams build scalable, reliable data infrastructure.
Get in touch →Share this briefing
Related Posts
How Distributed SQL Execution Engines Really Work
How distributed SQL execution actually works—query planning, data movement, joins, aggregations, and why your queries sometimes explode in cost.
Why HTAP Systems Fail Quietly (And How to Notice Early)
HTAP systems don't fail with a bang; they fail sideways through memory contention and silent performance drift. Learn why mixed workloads are a silent production risk.
Real-Time Analytics: Trade-offs and Best Practices
Explore the architectural decisions and trade-offs when building real-time analytics systems, from data ingestion to query optimization.