Advanced indexing: composite, covering and FULLTEXT
Order composite indexes deliberately, make hot queries covered, and reach for prefix, functional, invisible and FULLTEXT indexes when a plain B-tree is not the right shape.
Composite order, covering and prefix indexes
A composite index is a sorted structure over several columns in a fixed order, usable from the left. Within that rule you still have choices: put equality columns before range columns, and consider making the index cover the query so the table rows are never visited.
-- equality first, then the range
CREATE INDEX idx_orders_status_placed ON orders (status, placed_at);
-- covering: every column the query needs is in the index
CREATE INDEX idx_orders_cover ON orders (customer_id, placed_at, total);
EXPLAIN SELECT customer_id, placed_at, total
FROM orders
WHERE customer_id = 42
ORDER BY placed_at DESC; -- Extra: Using index
-- prefix index on a long text column, when the full value is too wide
CREATE INDEX idx_users_email_prefix ON users (email(20));
-- descending index (8.0+), useful for an equality prefix with a DESC sort
CREATE INDEX idx_events_desc ON events (actor_id, created_at DESC);| Plan output | What it means |
|---|---|
type: ref | Equality seek on a non-unique index |
type: range | The index was used for a bounded scan |
Extra: Using index | Covering index: rows were answered from the index alone |
Extra: Using index condition | Index condition pushdown filtered inside the storage engine |
Extra: Using where | Rows were fetched and then filtered by the server |
Extra: Using filesort | No index supplied the order, so the result was sorted afterwards |
FULLTEXT, functional and invisible indexes
ALTER TABLE articles ADD FULLTEXT INDEX ft_articles (title, body);
SELECT id,
MATCH(title, body) AGAINST ('connection pool' IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE MATCH(title, body) AGAINST ('connection pool' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT 10;
-- ngram parser: needed for scripts that do not separate words with spaces
ALTER TABLE articles
ADD FULLTEXT INDEX ft_body_ngram (body) WITH PARSER ngram;
-- hide an index from the optimiser without dropping it
ALTER TABLE orders ALTER INDEX idx_orders_cover INVISIBLE;
-- index an expression instead of the raw column (8.0.13+)
ALTER TABLE users ADD INDEX idx_lower_email ((LOWER(email)));MATCH ... AGAINSTmust appear in bothSELECTandWHEREfor the score to be usable for ordering.- Full-text indexes have their own relevance ranking and stopword list; the default minimum token length makes short words unsearchable until you change
innodb_ft_min_token_size. - A functional index is only used when the query contains the same expression. If you index
LOWER(email), a predicate on the bareemailcolumn will not seek.
💡
An invisible index is still maintained on every write; it is only hidden from the optimiser. That makes it a safe experiment: make an index invisible, watch the plan and the latency, and drop it only if nothing changed.
Statistics and index maintenance
ANALYZE TABLErefreshes the cardinality estimate. An estimate that is badly wrong is worse than no estimate, because the optimiser will confidently choose a plan that looks cheap and is not.- A histogram over a non-indexed column improves selectivity estimates for skewed data. The depth of per-index statistics is controlled by
innodb_stats_persistent_sample_pages, and more pages means slower but better statistics. - Index merge lets the optimiser combine two indexes with
UNIONorINTERSECT. It is a fallback: one correctly ordered composite index almost always beats it. - An index that is a strict left prefix of another is redundant.
(a)adds nothing on top of(a, b)and still costs writes. - Reads scale with indexes, writes pay for them.
performance_schema.table_io_waits_summary_by_index_usageshows which indexes have never been read.
| Symptom | First thing to check |
|---|---|
| Index exists but is ignored | A function, a type mismatch or a leading wildcard in the predicate |
| Only part of a composite index is used | Column order against the predicate shape |
| Plan changes after data grows | Stale statistics, or the predicate stopped being selective |
| Writes got slower with no schema change | New indexes added by a migration |
FAQ
How many indexes are too many?
There is no magic number, only a cost. Every index adds work to each insert, update and delete and takes space in the buffer pool. Drop the ones with zero reads and check that no index is a redundant prefix of another.
Can FULLTEXT replace a search engine?
For simple word matching inside MySQL it is transactional and fast. It lacks relevance tuning, stemming for every language, faceting and fuzzy matching, so once search quality becomes a product requirement, move it to a dedicated engine.
Related
Indexes and reading EXPLAIN Query optimisation and diagnostics
Last refreshed 2026-09-18.