MySQL full table scans: Handler_read_rnd_next and the missing index
You get paged because query latency is spiking. Slow_queries is climbing. Handler_read_rnd_next has jumped five-fold over its baseline and keeps rising. Connections, CPU, and buffer pool hit ratio look fine. The culprit is usually a new full table scan after a deploy, schema change, or query pattern shift.
Handler_read_rnd_next increments when the storage engine reads the next row during a table scan or sorted retrieval. In a healthy OLTP system, Handler_read_key dominates and Handler_read_rnd_next stays flat relative to query volume. When the ratio inverts, you are looking at a query plan regression, a missing index, or an optimizer decision gone wrong.
This guide targets OLTP MySQL with InnoDB. Batch jobs and ETL are expected to scan tables; the danger is an unexpected step-change during transactional workload.
What this means
Handler_read_rnd_next is a cumulative, monotonically increasing status counter that tracks sequential row reads. A full table scan of a one-million-row table adds roughly one million to this counter. It increments even when no rows satisfy the WHERE clause.
Handler_read_key tracks index-based lookups. When a query that previously used an index reverts to a table scan, you see a step-change in the rnd_next rate. If that correlates with rising Slow_queries, the scan is the problem.
The direct confirmation is EXPLAIN showing type = ALL and key = NULL, meaning the optimizer found no usable index. Other signals are Select_full_join and rising Sort_merge_passes when scan results are sorted on disk.
flowchart TD
A[Handler_read_rnd_next step-change >5x] --> B{Rising Slow_queries?}
B -->|Yes| C[New scan query appeared]
B -->|No| D[Expected batch/ETL load]
C --> E[EXPLAIN type=ALL]
E --> F{Root cause}
F --> G[Missing index]
F --> H[Implicit type conversion]
F --> I[Function on indexed column]
F --> J[Stale statistics]
F --> K[Missing replica index]Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Missing or dropped index | Handler_read_rnd_next step-change after a deploy or migration; EXPLAIN shows type=ALL | EXPLAIN output for key=NULL and possible_keys |
| Implicit type conversion | An index exists but EXPLAIN lists it in possible_keys while key is NULL; comparing a VARCHAR column to a numeric literal | SHOW CREATE TABLE for column types versus query predicates |
| Function on an indexed column | Query uses WHERE YEAR(created_at) = 2024; the index on created_at cannot be used | Query text for functions wrapping column names |
| Stale optimizer statistics | A new index was added but the optimizer still chooses a scan; cardinality estimates are wrong | SHOW INDEX cardinality or run ANALYZE TABLE |
| Missing index on a replica | Replica lag grows while the source is healthy; replica Handler_read_rnd_next is elevated | SHOW REPLICA STATUS and compare table indexes between source and replica |
| Sort buffer undersized | Large ORDER BY or GROUP BY operations spill to disk; Sort_merge_passes rising alongside scans | SHOW GLOBAL STATUS LIKE 'Sort_merge_passes' |
Quick checks
Run these read-only checks to confirm scope before making changes.
-- Global scan activity versus keyed lookups
SHOW GLOBAL STATUS WHERE Variable_name IN ('Handler_read_rnd_next','Handler_read_key','Slow_queries');
-- Queries responsible for full table scans, ordered by total latency
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;
-- Tables with the highest cumulative full-scan wait time
SELECT * FROM sys.schema_tables_with_full_table_scans LIMIT 10;
-- Joins without any index usage
SHOW GLOBAL STATUS LIKE 'Select_full_join';
-- Replica scan load and lag (8.0.22+)
SHOW REPLICA STATUS\G
SHOW GLOBAL STATUS LIKE 'Handler_read_rnd_next';
-- Per-query digest efficiency: rows examined versus rows sent
SELECT DIGEST_TEXT, SUM_ROWS_EXAMINED, SUM_ROWS_SENT,
ROUND(SUM_ROWS_EXAMINED/NULLIF(SUM_ROWS_SENT,0),2) AS examined_to_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
How to diagnose it
Confirm the regression from baseline. A
Handler_read_rnd_nextstep-change greater than 5x sustained for ten minutes, combined with risingSlow_queries, means a new scan query is active. Exclude expected batch windows.Isolate the offending query. Query
sys.statements_with_full_table_scansto see which digests are scanning. Alternatively, queryperformance_schema.events_statements_summary_by_digestfor digests with a highSUM_ROWS_EXAMINEDtoSUM_ROWS_SENTratio. Focus on digests that are new or whose latency recently jumped.Verify the execution plan. Run
EXPLAIN(orEXPLAIN ANALYZE) on the suspect query. Look fortype = ALLandkey = NULL. Ifpossible_keyslists an index butkeyis NULL, the optimizer judged the index too expensive, often due to low cardinality or stale statistics.Check for implicit conversion and function wrapping. Run
SHOW CREATE TABLEand compare column types to query predicates. AVARCHARcompared to a numeric literal forces conversion and kills index usage. Look for SQL functions around indexed columns, such asYEAR(),DATE(), orCONCAT(), which prevent index access.Measure the per-query handler cost. In a dedicated diagnostic session, run:
FLUSH STATUS; -- run the suspect query here SHOW SESSION STATUS LIKE 'Handler_read%';This resets the session counters, executes the query, and shows the delta.
Handler_read_rnd_nextwill be roughly equal to the number of rows scanned.Check replicas if lag is present. If the source is healthy but replication lag is growing, compare indexes. A missing replica index causes row events to trigger table scans on the applier. Elevated
Handler_read_rnd_nexton the replica while the source is flat confirms it.Validate statistics. If an index exists but the optimizer ignores it, run
ANALYZE TABLE tablenameto refresh cardinality estimates. This acquires a read lock, so run it during a low-traffic window on large tables.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
Handler_read_rnd_next rate | Primary indicator of full table scan volume | Step-change >5x baseline sustained for 10+ minutes |
Handler_read_key rate | Healthy index lookup baseline | Ratio to rnd_next collapsing or rnd_next exceeding it |
Slow_queries rate | Confirms scans are translating to user-visible latency | Rising concurrently with Handler_read_rnd_next |
Select_full_join | Catches joins with no usable index | Any sustained nonzero rate in OLTP |
Innodb_rows_read / Com_select | Rows examined per query | Ratio climbing significantly above baseline |
Sort_merge_passes | Sort buffer overflow forcing disk merges | Rate rising alongside scan activity |
Fixes
Add or restore the missing index
If EXPLAIN shows no usable index and the query filters on a column that supports it, add the index. Tradeoff: every additional index slows writes and consumes disk space. For write-heavy tables, prefer composite indexes that satisfy multiple queries over many single-column indexes.
Rewrite predicates to avoid functions on columns
If the query wraps an indexed column in a function, rewrite the predicate to a range. For example, replace WHERE YEAR(created_at) = 2024 with WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'. Tradeoff: requires application or stored procedure changes.
Fix implicit type conversion
When EXPLAIN lists an index in possible_keys but does not use it, check for type mismatches. Ensure the query passes string literals for VARCHAR columns and numeric literals for integer columns. Tradeoff: may require binding changes in the application or ORM layer.
Force an index hint for emergency relief
If a plan regression appeared suddenly and you need immediate relief, FORCE INDEX can revert to the previous plan. Tradeoff: hints override the optimizer and can make performance worse if data distribution changes later. Remove the hint once the root cause is fixed.
Run ANALYZE TABLE after index changes
After adding an index, the optimizer may still choose a scan if statistics are stale. ANALYZE TABLE tablename updates cardinality estimates. Tradeoff: acquires a read lock on the table for a short duration on large tables.
Add missing indexes on replicas
If a replica is missing an index that exists on the source, rebuild it. The replica will apply row events using the index and scan rates will drop. Tradeoff: replica write overhead increases, but that is usually preferable to unbounded lag.
Prevention
- Track the scan-to-lookup ratio. A gradual shift toward
Handler_read_rnd_nextdominance is an early warning that data growth is outpacing your indexing strategy. - Monitor
Select_full_joinin OLTP. Any sustained nonzero rate means a join is missing an index; latency degrades as table size grows. - Lower
long_query_timeto 1 second or less. The default of 10 seconds hides plan regressions until they become severe enough to cause connection pile-up. - Review execution plans before deploys. A single unindexed
WHEREclause on a large table can dominate server-wide scan counters. - Verify replica index parity in schema changes. Dropping an index on the source without checking replicas guarantees full scans on the applier and growing lag.
How Netdata helps
- Netdata plots
Handler_read_rnd_nextandHandler_read_keyas per-second rates, making step-changes visible without manual delta calculations. - Correlation charts show when
Handler_read_rnd_nextspikes alongsideSlow_queries,Threads_running, orSelect_full_join, shortening time to root cause. - Per-second resolution catches scan bursts that minute-granularity aggregations smooth over.
- Netdata automatically baselines throughput signals like
QuestionsandInnodb_rows_read, so a plan regression that raises rows examined per query stands out immediately.
Related guides
- How MySQL actually works in production: a mental model for operators
- MySQL Aborted_connects and Aborted_clients climbing: diagnosis
- MySQL adaptive hash index latch contention: high CPU, low throughput
- MySQL InnoDB buffer pool hit ratio collapse: the cliff edge
- MySQL slow after restart: buffer pool warm-up and the cold cache
- MySQL innodb_buffer_pool_size tuning: 60-80% of RAM and when that breaks
- MySQL Innodb_buffer_pool_wait_free > 0: buffer pool memory pressure
- MySQL InnoDB checkpoint age: the redo log capacity signal nobody watches
- MySQL connection exhaustion: detection, diagnosis, and prevention
- MySQL innodb_deadlock_detect=OFF: when deadlock detection becomes the bottleneck
- MySQL ERROR 1213: Deadlock found when trying to get lock; try restarting transaction
- MySQL FLUSH TABLES WITH READ LOCK stall: backups that freeze the server







