Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/pingcap/tidb/llms.txt

Use this file to discover all available pages before exploring further.

TiDB’s cost-based optimizer (CBO) automatically selects execution plans, but understanding how to read plans and guide the optimizer is essential for tuning queries in production.

Reading execution plans with EXPLAIN

EXPLAIN shows the operator tree TiDB will use to execute a query without actually running it.
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
+-------------------------------+----------+-----------+---------------+--------------------------------+
| id                            | estRows  | task      | access object | operator info                  |
+-------------------------------+----------+-----------+---------------+--------------------------------+
| IndexLookUp_10                | 10.00    | root      |               |                                |
| ├─IndexRangeScan_8(Build)     | 10.00    | cop[tikv] | table:orders, | range:[42,42], keep order:false|
|                               |          |           | index:idx_cid |                                |
| └─TableRowIDScan_9(Probe)     | 10.00    | cop[tikv] | table:orders  | keep order:false               |
+-------------------------------+----------+-----------+---------------+--------------------------------+

EXPLAIN FORMAT=‘brief’

FORMAT='brief' produces a compact output that is easier to read for large plans:
EXPLAIN FORMAT='brief' SELECT * FROM orders WHERE customer_id = 42;
+-----------------+----------+------+-----------+--------------------------------+
| id              | estRows  | task | access    | operator info                  |
+-----------------+----------+------+-----------+--------------------------------+
| IndexLookUp     | 10.00    | root |           |                                |
| ├─IndexRange    | 10.00    | cop  | idx_cid   | range:[42,42]                  |
| └─TableRowID    | 10.00    | cop  | orders    |                                |
+-----------------+----------+------+-----------+--------------------------------+

EXPLAIN ANALYZE

EXPLAIN ANALYZE runs the query and augments the plan with actual runtime statistics, including row counts and execution time:
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Key columns added at runtime:
ColumnDescription
actRowsActual number of rows processed
execution infoTime spent in each operator
memoryMemory consumed by the operator
diskSpill-to-disk volume
A large discrepancy between estRows and actRows indicates stale statistics — run ANALYZE TABLE to update them.

Common operators

OperatorDescription
TableFullScanFull table scan; no usable index
TableRangeScanScan a range of the primary key
IndexFullScanFull scan of an index
IndexRangeScanScan a range within an index
IndexLookUpIndex scan followed by a row fetch (two-phase)
HashJoinHash-based join; builds a hash table on the smaller side
MergeJoinSort-merge join; requires sorted input on join keys
HashAggHash-based aggregation
StreamAggStreaming aggregation on pre-sorted input
ProjectionEvaluates expressions and selects columns
SelectionFilters rows by a predicate

Index hints

Index hints override the optimizer’s index selection for a specific query.
-- Prefer a specific index
SELECT * FROM orders USE INDEX (idx_status) WHERE status = 'pending';

-- Force a specific index (error if index cannot be used)
SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 'pending';

-- Exclude an index from consideration
SELECT * FROM orders IGNORE INDEX (idx_status) WHERE status = 'pending';
FORCE INDEX causes the query to fail rather than fall back to a full table scan if the index is unusable. Use USE INDEX when you want a preference without a hard requirement.

Optimizer hints

Optimizer hints go inside /*+ ... */ comments immediately after the SELECT, UPDATE, or DELETE keyword.

Join hints

-- Force a hash join between t1 and t2
SELECT /*+ HASH_JOIN(t1, t2) */ *
FROM orders t1
JOIN customers t2 ON t1.customer_id = t2.id;

-- Force a merge join
SELECT /*+ MERGE_JOIN(t1, t2) */ *
FROM orders t1
JOIN customers t2 ON t1.customer_id = t2.id;

-- Force an index nested-loop join
SELECT /*+ INL_JOIN(t2) */ *
FROM orders t1
JOIN customers t2 ON t1.customer_id = t2.id;

Index hints

-- Hint the optimizer to use a specific index
SELECT /*+ USE_INDEX(orders, idx_status) */ *
FROM orders
WHERE status = 'pending';

-- Ignore an index
SELECT /*+ IGNORE_INDEX(orders, idx_status) */ *
FROM orders
WHERE status = 'pending';

Other common hints

-- Read from a follower replica (for read-heavy workloads)
SELECT /*+ READ_FROM_STORAGE(TIFLASH[t]) */ count(*) FROM large_table t;

-- Set the maximum execution time for this query (ms)
SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM orders;

Updating statistics

TiDB’s optimizer relies on table statistics to estimate row counts. Stale statistics are one of the most common causes of poor plan choices.
-- Update statistics for a table
ANALYZE TABLE orders;

-- Update statistics for specific columns and indexes
ANALYZE TABLE orders COLUMNS customer_id, status INDEX idx_status;
TiDB runs auto-analyze in the background when a table’s data changes significantly. You can configure the auto-analyze threshold with the tidb_auto_analyze_ratio system variable.
Check when a table was last analyzed:
SELECT table_name, last_analyzed_at, row_count
FROM information_schema.tidb_table_stats
WHERE table_schema = 'mydb';

Slow query log

TiDB records queries exceeding tidb_slow_log_threshold (default 300 ms) to the slow query log and the INFORMATION_SCHEMA.SLOW_QUERY table.
-- Find the slowest recent queries
SELECT query, query_time, parse_time, compile_time, process_time
FROM information_schema.slow_query
ORDER BY query_time DESC
LIMIT 10;

-- Filter by table
SELECT query, query_time, index_names
FROM information_schema.slow_query
WHERE query LIKE '%orders%'
ORDER BY query_time DESC
LIMIT 20;

Common optimization patterns

Use covering indexes

A covering index includes all columns referenced in the query, eliminating the second lookup phase (IndexLookUpIndexFullScan):
-- Without covering index: IndexLookUp (two phases)
SELECT status, created_at FROM orders WHERE customer_id = 42;

-- Create a covering index
CREATE INDEX idx_cid_cover ON orders (customer_id, status, created_at);

-- Now: IndexRangeScan only (one phase)
SELECT status, created_at FROM orders WHERE customer_id = 42;

Avoid full table scans

A TableFullScan on a large table is almost always a performance problem. Common causes:
  • No index on the filtered column.
  • Filtering on a function applied to an indexed column: WHERE YEAR(created_at) = 2024 prevents index use; rewrite as WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'.
  • Leading column of a composite index not included in the WHERE clause.

Choose the right join algorithm

ScenarioRecommended join
One small table, one large tableHASH_JOIN or INL_JOIN
Both tables sorted on join keyMERGE_JOIN
High-cardinality equi-joinHASH_JOIN
Index available on join keyINL_JOIN

Prefer TIFLASH for analytical queries

For large aggregations and scans, push computation to TiFlash:
SELECT /*+ READ_FROM_STORAGE(TIFLASH[orders]) */
    status, SUM(amount), COUNT(*)
FROM orders
GROUP BY status;

Build docs developers (and LLMs) love