Query optimization is the skill I’ve spent the most years refining, and honestly, I don’t think I’ll ever consider it “finished” — every new dataset size, every new access pattern teaches me something new. In this article, I’m bringing together everything I’ve learned about optimizing MySQL queries, from understanding the optimizer’s decision-making process to real production incidents I’ve resolved.
How the MySQL Optimizer Thinks
Before optimizing anything, I remind myself what the optimizer’s actual job is: given a query, it evaluates multiple possible execution plans (which indexes to use, which order to join tables, whether to use a temporary table, whether to sort in memory or on disk) and picks the one it estimates will be cheapest, based on internal cost calculations and table statistics.
graph TD
A[SQL Query] --> B[Optimizer Generates Candidate Plans]
B --> C[Cost Estimation Using Table Statistics]
C --> D[Cheapest Plan Selected]
D --> E[Execution Engine Runs Plan]
E --> F[Storage Engine Fetches Rows]
Because the optimizer relies on statistics, one of the very first things I check when a query behaves unexpectedly is whether those statistics are stale:
ANALYZE TABLE orders;
Step 1: Always Start With EXPLAIN
EXPLAIN SELECT o.order_id, c.full_name, o.order_total
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 20;
I pay close attention to these columns every time:
| Column | What I Look For |
|---|---|
type | Ideally const, eq_ref, ref, or range. ALL means a full table scan — a red flag on large tables. |
key | Which index (if any) was actually used |
rows | Estimated rows examined — lower is better relative to actual result size |
Extra | Watch for Using filesort and Using temporary, both signs of extra work the optimizer had to do |
Step 2: Using EXPLAIN ANALYZE for Real Execution Data
EXPLAIN alone only shows the estimated plan. Since MySQL 8.0.18, I use EXPLAIN ANALYZE to see actual execution timing:
EXPLAIN ANALYZE
SELECT o.order_id, c.full_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'pending';
This shows real timing per operation in the plan tree, which is invaluable when the optimizer’s row estimates and reality diverge significantly.
Common Query Anti-Patterns I Fix Constantly
1. Functions wrapped around indexed columns
-- Bad: prevents index usage on created_at
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- Good: allows index range scan
SELECT * FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-12-31';
2. Leading wildcard LIKE searches
-- Bad: cannot use a B+Tree index at all
SELECT * FROM customers WHERE email LIKE '%@gmail.com';
-- Better: can use an index if email starts with the search term
SELECT * FROM customers WHERE email LIKE 'ahmad%';
For genuine substring search needs, I use a FULLTEXT index instead of fighting LIKE.
3. SELECT * on wide tables
-- Bad: pulls unnecessary columns, prevents covering index usage
SELECT * FROM products WHERE category_id = 5;
-- Good: matches a covering index exactly
SELECT product_id, name, price FROM products WHERE category_id = 5;
4. Implicit type conversions
-- Bad: comparing a string column to a number can silently prevent index usage
SELECT * FROM customers WHERE phone = 3001234567;
-- Good: matching data types
SELECT * FROM customers WHERE phone = '3001234567';
5. OR conditions across different columns
-- Often forces a full scan since a single index can't satisfy both branches
SELECT * FROM orders WHERE customer_id = 5 OR status = 'pending';
-- Rewritten with UNION, letting each half use its own index
SELECT * FROM orders WHERE customer_id = 5
UNION
SELECT * FROM orders WHERE status = 'pending';
Understanding Using filesort and Using temporary
When I see Using filesort in Extra, it means MySQL had to sort results outside of what an index already provides. When I see Using temporary, it means MySQL created an internal temporary table, often for GROUP BY or DISTINCT queries that can’t be satisfied directly from an index.
CREATE INDEX idx_status_created ON orders(status, created_at);
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
Because the composite index already stores rows sorted by status then created_at, this query avoids Using filesort entirely — MySQL can walk the index in the correct order directly.
Query Optimization for Joins
I always make sure join columns are indexed on both sides, and I think carefully about join order for complex multi-table queries, though I let the optimizer handle this in most cases:
EXPLAIN SELECT c.full_name, o.order_total, p.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE c.id = 501;
I verify each join step in the EXPLAIN output uses eq_ref or ref rather than ALL, which would indicate a missing index on one of the join columns.
Using the Query Optimizer’s Hints (When Necessary)
Occasionally, I encounter a case where the optimizer picks a suboptimal plan due to skewed data distribution. In these rare cases, I use optimizer hints to nudge it:
SELECT /*+ INDEX(orders idx_status_created) */ *
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC;
I treat hints as a last resort though — they’re brittle and can become actively harmful if data distribution changes later, so I always re-verify periodically rather than leaving hints in place forever without review.
Caching Strategies I Layer on Top of Query Optimization
Beyond the query itself, I often reduce database load using:
- Application-level caching (Redis/Memcached) for frequently accessed, rarely changing data like product catalogs.
- Materialized summary tables updated via scheduled jobs or triggers for expensive aggregate reports, rather than recomputing them live on every dashboard load.
CREATE TABLE daily_sales_summary (
summary_date DATE PRIMARY KEY,
total_orders INT,
total_revenue DECIMAL(12,2)
);
A Real-World Scenario: Optimizing a Slow Search Feature
A client’s product search endpoint was timing out under moderate load. The original query:
SELECT * FROM products
WHERE name LIKE '%wireless%' OR description LIKE '%wireless%'
ORDER BY created_at DESC;
I diagnosed the issues:
- Leading wildcard
LIKEon both columns prevented any index usage. SELECT *pulled a largedescriptionTEXT column unnecessarily for a listing view.ORDER BY created_atwithout a matching index caused a filesort on every request.
My fix:
ALTER TABLE products ADD FULLTEXT INDEX idx_ft_search (name, description);
SELECT product_id, name, price, created_at
FROM products
WHERE MATCH(name, description) AGAINST('wireless' IN NATURAL LANGUAGE MODE)
ORDER BY created_at DESC
LIMIT 20;
Response time dropped from roughly 4.2 seconds to under 60 milliseconds under the same load, purely from restructuring the query and adding the right index type for the actual access pattern.
Server-Level Configuration Tuning
Beyond individual queries, I tune several server variables that affect overall query performance:
innodb_buffer_pool_size = 8G
innodb_io_capacity = 2000
innodb_flush_log_at_trx_commit = 1
tmp_table_size = 64M
max_heap_table_size = 64M
sort_buffer_size = 2M
join_buffer_size = 2M
innodb_buffer_pool_sizeis the single highest-impact setting — I size it to roughly 60-70% of available RAM on a dedicated database server, since a larger buffer pool means more data can be served from memory instead of disk.tmp_table_sizeandmax_heap_table_sizecontrol how large an in-memory temporary table can grow before MySQL spills it to disk — I increase these if I see frequent disk-based temp tables in slow query logs.
Using the Slow Query Log
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
I review this log regularly, and I use pt-query-digest (from the Percona Toolkit) to aggregate and rank the worst-offending query patterns rather than reading raw log entries one by one.
pt-query-digest /var/log/mysql/slow.log
Security Considerations Related to Optimization
- I never sacrifice parameterized queries for performance — SQL injection risk is never an acceptable tradeoff for speed.
- I’m cautious with overly permissive caching of query results that might include per-user sensitive data, ensuring cache keys are properly scoped per user/tenant.
- I restrict access to the slow query log and performance schema tables, since they can reveal query patterns and occasionally sensitive literal values.
Troubleshooting Checklist I Follow for Any Slow Query
- Run
EXPLAIN(andEXPLAIN ANALYZEif available) first. - Check whether relevant columns are indexed, and whether the leftmost prefix rule is satisfied for composite indexes.
- Check for functions wrapping indexed columns in
WHEREclauses. - Check for
Using filesortorUsing temporaryinExtra. - Run
ANALYZE TABLEto refresh statistics if row estimates look wildly wrong. - Check server-level buffer pool sizing and disk I/O capacity.
- Consider caching or a materialized summary table if the query is inherently expensive (like large aggregations) regardless of indexing.
Performance Best Practices Summary
- Index based on actual query patterns, verified with
EXPLAIN. - Avoid functions on indexed columns and leading wildcards in
LIKE. - Select only needed columns, avoiding
SELECT *. - Use covering indexes for read-heavy, high-frequency queries.
- Monitor the slow query log continuously, not just when users complain.
- Size
innodb_buffer_pool_sizeappropriately for the server’s available RAM.
Frequently Asked Questions
Q: What’s the very first thing I should do when a query is slow? A: Run EXPLAIN (or EXPLAIN ANALYZE) before touching anything else — guessing at the cause without seeing the actual execution plan wastes time.
Q: Why would MySQL ignore an index that clearly matches my WHERE clause? A: Common causes include a function wrapping the indexed column, a data type mismatch, stale table statistics, or the optimizer genuinely calculating that a full scan is cheaper for a small table.
Q: Are query hints a good long-term solution? A: I treat them as a temporary, closely-monitored fix rather than a permanent solution, since they can become counterproductive as data distribution changes over time.
Q: How often should I run ANALYZE TABLE? A: I run it after significant bulk data changes, and some environments schedule it periodically (e.g., nightly) for tables with heavy write activity.
Interview Questions I’ve Encountered
- Walk through your process for diagnosing a slow MySQL query from scratch.
- What’s the difference between
EXPLAINandEXPLAIN ANALYZE? - Why does a leading wildcard in a
LIKEclause prevent index usage? - What do
Using filesortandUsing temporarymean in an EXPLAIN plan, and how would you eliminate them? - How would you tune
innodb_buffer_pool_sizefor a server with 32GB of RAM dedicated to MySQL? - When, if ever, would you use optimizer hints, and what are the risks?
Summary and Key Takeaways
Query optimization is where all my other MySQL knowledge — indexing, storage engine internals, and server configuration — comes together in practice. I never guess at the cause of a slow query; I start with EXPLAIN, verify my assumptions, and only then make a targeted change, whether that’s an index, a query rewrite, or a server-level tuning adjustment.
Key takeaways:
- Always start troubleshooting with
EXPLAINorEXPLAIN ANALYZE. - Avoid common anti-patterns: functions on indexed columns, leading wildcards,
SELECT *. - Watch for
Using filesortandUsing temporaryas signals for index improvements. - Tune
innodb_buffer_pool_sizeand related server variables for your actual hardware. - Use the slow query log and tools like pt-query-digest to prioritize what to fix first.
References
- MySQL 8.0 Reference Manual, Optimization: https://dev.mysql.com/doc/refman/8.0/en/optimization.html
- MySQL EXPLAIN Output Format: https://dev.mysql.com/doc/refman/8.0/en/explain-output.html
- MySQL Slow Query Log: https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html
- Percona Toolkit Documentation: https://docs.percona.com/percona-toolkit/