Why Slow MySQL Queries Are a Symptom, Not the Problem
MySQL query is rarely just a slow query. It is a signal that something deeper is wrong: a missing index, a poorly planned join, a misconfigured server.

Why Slow Queries Are a Symptom, Not the Problem
A slow MySQL query is rarely just a slow query. It is a signal that something deeper is wrong: a missing index, a poorly planned join, a misconfigured server, or a query that worked fine at 10,000 rows and fell apart at 10 million.
Engineers who fix these issues fastest understand what MySQL is actually doing internally, how it builds execution plans, when it ignores indexes, and what forces it to sort on disk. This guide covers that behaviour and shows you how to act on it.
Why MySQL Queries Become Slow Internally
When you run a query, MySQL's query optimizer builds an execution plan, a step-by-step strategy for retrieving the data. The optimizer estimates the cost of different strategies based on index statistics, table cardinality, and row counts, then selects the strategy it believes is cheapest.
The problems start here. The optimizer is working with estimates, not certainties. Stale statistics, skewed data distributions, or low-cardinality columns can push it toward a plan that looks cheap on paper but is expensive in practice.
Full Table Scans
A full table scan means MySQL reads every row in the table to find matches. On a table with a million rows and no useful index, this is catastrophic. The EXPLAIN output will show type = ALL — the worst possible access type.
Filesort and Temporary Tables
When MySQL cannot satisfy an ORDER BY or GROUP BY using an index, it sorts the data in memory or on disk — a filesort operation. When GROUP BY or complex subqueries require intermediate storage, MySQL creates temporary tables. Both appear in EXPLAIN's Extra column and are expensive signals to watch for.
Cardinality and the Optimizer Trap
Index cardinality is the number of unique values in an indexed column. A column like status with values active and inactive has cardinality of 2 — very low. MySQL often decides a full table scan is cheaper than using such an index, because it estimates it will scan most rows anyway. The optimizer is not wrong, but the result can still surprise developers expecting an index to be used.
How to Detect Slow Queries in Production
Enabling the Slow Query Log
The slow query log is your first diagnostic tool. Enable it and set a threshold — any query taking longer than that value is logged with its execution time, lock time, and rows examined.
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- log queries over 1 second
SET GLOBAL log_queries_not_using_indexes = 'ON';The rows_examined value in each log entry is particularly useful. A query returning 50 rows but examining 500,000 is doing far more work than it should — a clear index problem.
Using performance_schema
For production systems where you cannot enable file logging freely, performance_schema provides query-level statistics without restarting the server. The events_statements_summary_by_digest table is your starting point — it groups similar queries and shows average execution time, total rows examined, and execution count.
SELECT digest_text, avg_timer_wait/1e12 AS avg_sec,
count_star, sum_rows_examined
FROM performance_schema.events_statements_summary_by_digest
ORDER BY avg_timer_wait DESC LIMIT 10;Reading EXPLAIN and EXPLAIN ANALYZE
EXPLAIN shows you the execution plan MySQL has chosen before running the query. EXPLAIN ANALYZE actually runs it and returns both estimated and actual costs — far more useful for diagnosing real performance.
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;The key columns to focus on:
Column | What It Tells You | Warning Sign |
|---|---|---|
type | How MySQL accesses the table | ALL = full scan; range or ref is better |
key | Which index is being used | NULL means no index is used |
rows | Estimated rows MySQL will examine | High number relative to result set |
Extra | Additional operations | Using filesort; Using temporary |
filtered | Percentage of rows passing WHERE conditions | Low percentage means lots of wasted work |
EXPLAIN ANALYZE adds actual row counts and loop times after each estimated value. When estimated rows = 1 but actual rows = 50,000, the optimizer was working with bad statistics. Running ANALYZE TABLE refreshes them.
Common Causes of MySQL Query Slowness
Missing or Unused Indexes
The most frequent cause by far. A query on orders.customer_id that runs a full table scan means either the column has no index, or MySQL has decided the index is not selective enough to use. Check with EXPLAIN, then add or adjust indexes accordingly.
SELECT * in Production Code
Fetching every column forces MySQL to read full rows even when only two columns are needed. It also prevents covering indexes from working. Always specify the columns you actually need.
Functions on Indexed Columns
Wrapping an indexed column inside a function breaks the index. MySQL cannot use a B-tree index on the result of a function — it must compute the function for every row first.
-- Breaks the index on created_at:
WHERE YEAR(created_at) = 2024
-- Use a range instead:
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'OR Conditions Across Columns
OR across different columns often forces full table scans because MySQL can only use one index per table per query in most cases. Restructuring as a UNION can allow each branch to use its own index.
The N+1 Query Problem
This is a common application-level mistake. An ORM fetches a list of 100 users, then for each user runs a separate query to fetch their orders — 101 queries instead of one. In Laravel Eloquent, the fix is eager loading:
-- N+1: triggers 101 queries
$users = User::all();
foreach ($users as $user) { $user->orders; }
-- Fixed: 2 queries total
$users = User::with('orders')->get();In Django, use select_related() for foreign keys and prefetch_related() for many-to-many. In Sequelize, use the include option. The pattern is the same across all ORMs — specify what you need upfront.
Index Optimization: Beyond the Basics
Composite Indexes and Column Order
A composite index on (last_name, first_name) can satisfy queries filtering on last_name alone, or on both columns together — but not on first_name alone. The leftmost prefix rule determines this. Put the most selective column first, and structure the index to match the WHERE clauses your queries actually use.
Covering Indexes
A covering index includes all columns a query needs — so MySQL never touches the actual table rows. EXPLAIN will show Using index in the Extra column. This is one of the highest-impact optimizations available:
-- Query needs id, status, created_at
CREATE INDEX idx_covering ON orders (customer_id, status, created_at);If the index covers everything the query selects, MySQL reads only the index structure, which is far smaller and faster than the full table.
Clustered vs Secondary Indexes
Property | Clustered Index (Primary Key) | Secondary Index |
|---|---|---|
Storage | Data rows stored in index order | Stores primary key + indexed column(s) |
Lookup cost | One read reaches the row | Two reads: index then primary key lookup |
In InnoDB | Always exists (auto-created on PK) | Each additional index you create |
Impact | Defines physical row ordering | Extra storage; affects write performance |
Over-indexing is a real problem. Each index adds overhead to INSERT, UPDATE, and DELETE operations because MySQL must keep all indexes consistent. Add indexes to solve real, measured problems — not speculatively.
Query Rewrite Techniques That Make a Real Difference
EXISTS vs IN for Subqueries
EXISTS short-circuits as soon as it finds a match. IN evaluates the full subquery and builds a result set. For large datasets, EXISTS is often significantly faster when checking for the presence of related rows.
-- Slower on large datasets:
WHERE customer_id IN (SELECT id FROM customers WHERE tier = 'gold')
-- Often faster:
WHERE EXISTS (SELECT 1 FROM customers WHERE customers.id = orders.customer_id AND tier = 'gold')Pagination at Scale
LIMIT 10 OFFSET 50000 is slow because MySQL scans and discards 50,000 rows. Keyset pagination avoids this entirely by filtering from the last seen row:
-- Slow OFFSET pagination:
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 50000;
-- Fast keyset pagination:
SELECT * FROM orders WHERE id > 50000 ORDER BY id LIMIT 10;MySQL Server Configuration That Affects Query Performance
Sometimes the query and its indexes are fine — the bottleneck is the server configuration. These are the variables that matter most in production:
Variable | What It Controls | Recommended Starting Point |
|---|---|---|
innodb_buffer_pool_size | Memory for caching InnoDB data and indexes | 70-80% of available RAM |
tmp_table_size | Max size of in-memory temporary tables | 64M-256M depending on workload |
sort_buffer_size | Memory per sort operation (per connection) | 2M-4M; avoid setting too high |
max_connections | Maximum simultaneous connections | Set based on application pool size |
innodb_io_capacity | I/O operations per second for background tasks | Match your disk IOPS |
The innodb_buffer_pool_size is the single most impactful configuration setting. If your working dataset fits in the buffer pool, disk I/O for reads drops dramatically. Check the buffer pool hit rate regularly — below 99% is a sign it is too small.
Optimizing for Tables With Millions of Rows
Table Partitioning
Partitioning splits a large table into smaller physical segments based on a column value — typically a date range. Queries filtering on the partition key only scan relevant partitions, skipping the rest entirely. This is called partition pruning and can reduce scan costs by orders of magnitude on time-series data.
Read Replicas and Caching
Read replicas offload SELECT queries from the primary server. Route reporting queries, analytics, and non-critical reads to replicas. Add a caching layer — Redis or Memcached — for queries whose results do not change frequently. Serving a cached result takes microseconds; even a fast SQL query takes milliseconds.
Archiving Old Data
Moving historical records to archive tables keeps active tables lean. A query on 2 million active orders behaves very differently than the same query on 200 million rows spanning a decade.
AI-Powered Query Optimization in 2026
A new category of tools analyzes query patterns, index usage, and execution plans at scale — and surfaces recommendations automatically.
Percona Monitoring and Management (PMM) provides query analytics with automated index recommendations based on real workload patterns.
OtterTune uses machine learning to tune MySQL configuration parameters — innodb_buffer_pool_size, sort_buffer_size, and others — based on observed workload behaviour.
GitHub Copilot and similar assistants can now flag N+1 issues and missing index usage in ORM code during development.
These tools surface the right problems faster. But you still need to understand EXPLAIN output and indexing principles to evaluate their suggestions correctly.
Production Optimization Workflow
Do not optimize blindly. Follow this sequence every time:
Identify: Use the slow query log or performance_schema to find the worst offenders by total execution time, not just single-run latency.
Benchmark: Capture baseline execution time with SELECT BENCHMARK() or an external timing tool. You need a number to beat.
Analyze: Run EXPLAIN ANALYZE and read the output carefully. Look at type, key, rows, and Extra.
Index first: Most slow queries are index problems. Add or adjust indexes before touching the query itself.
Rewrite if needed: If indexing does not solve it, rewrite the query — remove SELECT *, fix OR conditions, restructure subqueries.
Tune configuration: If query and index are fine but performance is still poor, look at innodb_buffer_pool_size, tmp_table_size, and disk I/O.
Test in production conditions: Test with production data volumes, not a small local copy. Results differ significantly.
Monitor for regressions: Schema changes elsewhere can invalidate a previously optimal plan. Add query performance to your monitoring dashboards.
Common Bottlenecks and Their Fixes
Symptom in EXPLAIN | Root Cause | Fix |
|---|---|---|
type = ALL | Full table scan, no index used | Add index on WHERE column |
Using filesort | Sort cannot use an index | Add index matching ORDER BY clause |
Using temporary | GROUP BY needs temp storage | Add index on GROUP BY column |
key = NULL | No index selected by optimizer | Check cardinality; consider composite index |
rows >> result size | Low-selectivity index or none | Review WHERE conditions; use covering index |
High lock time in log | Locking contention | Review transaction scope; use READ COMMITTED |
Common Questions
When should I add an index vs rewrite the query?
Try the index first. Most slow queries are missing one. Rewriting is valuable when query logic is fundamentally wrong — like OFFSET pagination or OR across columns — but indexes solve the majority of cases faster and with fewer side effects.
How do I know if my index is being used?
Run EXPLAIN. Check the key column — it shows which index MySQL selected. NULL means no index was used. The type column also matters: ref, range, or eq_ref are good; ALL means a full table scan.
Does MySQL 8 change anything?
Yes. MySQL 8 added invisible indexes, descending indexes, and better optimizer handling of CTEs and window functions. EXPLAIN ANALYZE also became significantly more detailed and reliable in MySQL 8.
Final Thoughts
Slow query optimization is detective work. The slow query log and EXPLAIN ANALYZE tell you where to look. Indexing and query rewrites tell you how to fix it. Start with the worst offender, run EXPLAIN ANALYZE, fix one thing at a time, and measure. Applied consistently, that process handles the vast majority of MySQL performance problems in production.
Share this article
Related Articles

New game article for check layout structure
Shown in post cards and search results. Required to publish.
fifth post
Shown in post cards and search results. Required to publish.
fourth post
Shown in post cards and search results. Required to publish.


