As applications scale from hundreds of thousands to millions of records, poorly optimized database queries quickly become the primary bottleneck of modern web architectures. While hardware has gotten faster, an unindexed query forcing a full table scan across millions of rows will easily exhaust server CPU, lock connection pools, and degrade the user experience.
In this guide, we will explore practical, battle-tested strategies to diagnose and optimize slow SQL queries in relational database systems like MySQL and SQL Server, concluding with a real-world case study where a composite index slashed query latency by over 1,000%.
1. Stop Using SELECT * in Production
One of the most common habits in early-stage development is querying all columns:
-- Avoid in production
SELECT * FROM orders WHERE customer_id = 4502;
Why this hurts performance:
-
I/O and Network Overhead: Retrieving large columns (
TEXT,VARCHAR(MAX), blobs) transfers unnecessary bytes across database connection buffers. - Prevents Covering Indexes: An index can only satisfy a query without reading the underlying clustered index (table heap) if all requested columns exist inside the index leaf nodes.
The Fix:
Only specify the exact columns your application logic or API needs:
SELECT order_id, total_amount, order_status, created_at
FROM orders
WHERE customer_id = 4502;
2. Diagnosing Bottlenecks with EXPLAIN
Before adding indexes blindly, always inspect how the database query planner executes your statement. In MySQL or PostgreSQL, prefix your query with EXPLAIN:
EXPLAIN SELECT order_id, total_amount
FROM orders
WHERE customer_id = 4502 AND order_status = 'COMPLETED';
Key Metrics to Inspect:
-
type: Look forALL(full table scan). You want to seeref,eq_ref, orrange. -
rows: Represents the estimated number of rows examined. If this number is in the tens of thousands for a simple lookup, an index is missing. -
key: Indicates which index the query planner selected. IfNULL, no index was used. -
Extra: Beware ofUsing filesortorUsing temporary. This means the database is sorting records in memory or spilling intermediate batches to disk.
3. Designing Multi-Column (Composite) Indexes Correctly
When querying against multiple conditions in a WHERE clause, a composite index is often the best solution. However, column order inside the index matters due to the Leftmost Prefix Rule.
Suppose you run:
SELECT order_id, total_amount
FROM orders
WHERE customer_id = 4502 AND order_status = 'COMPLETED';
Creating an index as:
CREATE INDEX idx_orders_customer_status
ON orders (customer_id, order_status);
How the Leftmost Prefix Rule Works:
- A search on
customer_idalone will use this index. - A search on
customer_idANDorder_statuswill use this index. - A search on
order_statusalone CANNOT use this index effectively.
Rule of thumb: Put columns with higher selectivity (more unique values, like foreign keys) before columns with low cardinality (like status enums).
4. Beware of Functions on Indexed Columns (SARGability)
A frequent mistake that invalidates index usage is wrapping indexed columns in functions inside the WHERE clause:
-- Inefficient: Database must calculate DATE() for every row in the table
SELECT order_id FROM orders
WHERE DATE(created_at) = '2026-09-01';
Because created_at is wrapped inside DATE(), the query engine cannot use the B-Tree index on created_at.
The SARGable Alternative:
Make the query SARGable (Search Argument Able) by comparing against an explicit range:
-- Efficient: Enables a fast index range scan
SELECT order_id FROM orders
WHERE created_at >= '2026-09-01 00:00:00'
AND created_at < '2026-09-02 00:00:00';
5. Case Study: Slashing Chat Message Latency by 1,000%+ with a 3-Column Composite Index
To see how index architecture behaves under real production pressure, consider a real-world high-throughput chat platform (such as a multi-tenant WhatsApp CRM).
In customer messaging feeds, a common query retrieves the latest 100 messages for an active conversation thread:
SELECT id, conversation_id, sender_phone, content, timestamp
FROM messages
WHERE conversation_id = 14205 AND is_hidden = 0
ORDER BY timestamp DESC, id DESC
LIMIT 100;
The Problem Before the Index
Initially, the messages table only possessed a primary key on id. Under production load:
-
Full Table Scan (
type: ALL): With hundreds of thousands of messages across all tenants, the database had to inspect every single row in storage to locate records whereconversation_id = 14205. -
Catastrophic Filesort (
Using filesort): Because the query orders bytimestamp DESC, id DESC, MySQL allocated space insort_buffer_size. As conversation histories grew, the sort buffer overflowed, spilling intermediate sorting runs to temporary disk files. - The Polling Concurrency Trap: With multiple agents polling the conversation endpoint every 1,500ms, database CPU utilization pinned at 100%, and connection pools were rapidly exhausted. Query latency averaged 1,200ms to 2,500ms.
The Solution: The 3-Column Composite Index
We introduced an ordered composite index specifically targeting the filter equality, sort order, and tie-breaker:
CREATE INDEX messages_conversation_timestamp_id_idx
ON messages (conversation_id, timestamp, id);
The Execution Plan Transformation
| Execution Metric | Before (No Composite Index) | After (conversation_id, timestamp, id) |
Improvement |
|---|---|---|---|
Access Type (type) |
ALL (Full Table Scan) |
ref (Const point seek) |
$O(N) \rightarrow O(\log N)$ |
| Key Used | NULL |
messages_conversation_timestamp_id_idx |
Targeted Seek |
| Sorting Strategy |
Using filesort (Disk Temp Tables) |
None (In-tree index order) | 0 bytes sort memory |
| Index Traversal | Scanned entire table | Backward index scan + Early exit | Stops after 100 rows |
| Query Latency | ~1,800 ms | ~1.8 ms | ~1,000x faster (100,000%) |
Why This Works Mechanically:
-
Equality Partitioning: The database navigates directly to
conversation_id = 14205in the B-Tree root in $O(\log N)$ time. -
Zero-Cost Sorting: Because the B-Tree leaf nodes are already physically ordered by
timestampandid, the engine executes a backward index scan. It does not sort a single byte in memory. -
Early Exit at
LIMIT 100: Once the engine traverses the leaf nodes to collect 100 matching rows, it immediately terminates execution, ignoring the remaining millions of records in the table.
6. Prefer JOIN over Correlated Subqueries
Correlated subqueries execute once for every row evaluated by the outer query ($O(N \times M)$ complexity).
-- Slow correlated subquery: executes subquery for every customer row
SELECT c.customer_name,
(SELECT SUM(o.total_amount) FROM orders o WHERE o.customer_id = c.customer_id) as total_spent
FROM customers c;
Converting this to a LEFT JOIN with GROUP BY allows the optimizer to execute an efficient hash join or index lookup:
-- Faster JOIN approach
SELECT c.customer_name, COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;
Production Indexing Rules of Thumb
-
Order composite index columns strategically: Equality filters first (
conversation_id = ?), followed by sort columns (timestamp DESC), followed by deterministic tie-breakers (id DESC). -
Inspect
EXPLAINforUsing filesort: If a high-frequency query showsUsing filesort, your index is failing to satisfy theORDER BYclause. -
Beware of over-indexing: Every index speeds up
SELECTstatements but incurs an I/O write penalty onINSERT,UPDATE, andDELETE. Target your most frequent, latency-sensitive query paths first.
By designing composite indexes that satisfy both filtering predicates and sorting criteria, you can routinely eliminate disk-bound filesorts and turn multi-second bottlenecks into sub-millisecond lookups.
Top comments (0)