MySQL FULLTEXT Index
A FULLTEXT index enables efficient natural-language text search across one or more TEXT or VARCHAR columns. Unlike a regular B-tree index (which only helps with prefix matches), a FULLTEXT index builds an inverted index of every word in the indexed text, making it possible to search for any word or phrase anywhere in a large body of text.
Creating a FULLTEXT Index
-- At table creation time CREATE TABLE articles ( id INT NOT NULL AUTO_INCREMENT, title VARCHAR(255) NOT NULL, body TEXT NOT NULL, created DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), FULLTEXT INDEX ft_article_content (title, body) ); -- On an existing table ALTER TABLE articles ADD FULLTEXT INDEX ft_article_content (title, body); -- Using CREATE INDEX syntax CREATE FULLTEXT INDEX ft_article_content ON articles (title, body); -- Remove a FULLTEXT index ALTER TABLE articles DROP INDEX ft_article_content;
CHAR, VARCHAR, or TEXT. You cannot create a FULLTEXT index on numeric or date columns.MATCH() AGAINST() Syntax
FULLTEXT searches are performed with MATCH(columns) AGAINST(search_string [mode]). The column list in MATCH must exactly match the columns in the FULLTEXT index.
-- Basic full-text search (natural language mode, default)
SELECT id, title
FROM articles
WHERE MATCH(title, body) AGAINST('mysql database');
-- MATCH() returns a relevance score — use it for ranking
SELECT id, title,
ROUND(MATCH(title, body) AGAINST('mysql database'), 4) AS relevance
FROM articles
WHERE MATCH(title, body) AGAINST('mysql database')
ORDER BY relevance DESC
LIMIT 10;Boolean Mode — Full Operator Reference
Boolean mode gives you explicit control over which words must or must not appear. Use operators to build precise search queries.
Operator | Meaning | Example | Effect |
|---|---|---|---|
+ | Word MUST be present | +mysql +index | Both words required |
- | Word MUST NOT be present | +mysql -oracle | Must have mysql, no oracle |
(none) | Word is optional — boosts score | mysql tutorial | Optional, increases relevance |
Wildcard (trailing only) | data* | Matches: data, database, datatype | |
"..." | Exact phrase match | "primary key" | Words adjacent in that order |
Increase contribution to rank | mysql tutorial | mysql weighted higher | |
< | Decrease contribution to rank | mysql <tutorial | tutorial weighted lower |
~ | Negate relevance (still matched) | ~beginner tutorial | beginner reduces score |
() | Group sub-expressions | +(mysql postgres) -oracle | mysql OR postgres, no oracle |
-- Must contain 'mysql', must not contain 'oracle'
SELECT title FROM articles
WHERE MATCH(title, body) AGAINST('+mysql -oracle' IN BOOLEAN MODE);
-- Must contain 'mysql' AND 'index'
SELECT title FROM articles
WHERE MATCH(title, body) AGAINST('+mysql +index' IN BOOLEAN MODE);
-- Exact phrase match — words must be adjacent
SELECT title FROM articles
WHERE MATCH(title, body) AGAINST('"primary key"' IN BOOLEAN MODE);
-- Wildcard: find 'data', 'database', 'datatype', etc.
SELECT title FROM articles
WHERE MATCH(title, body) AGAINST('data*' IN BOOLEAN MODE);
-- Complex: must have 'mysql', optionally 'tutorial' or 'guide', never 'paid'
-- 'guide' ranks higher than 'tutorial'
SELECT title, MATCH(title, body) AGAINST('+mysql >guide tutorial -paid' IN BOOLEAN MODE) AS score
FROM articles
WHERE MATCH(title, body) AGAINST('+mysql >guide tutorial -paid' IN BOOLEAN MODE)
ORDER BY score DESC;ORDER BY score.Relevance Score Explained (TF-IDF)
The relevance score returned by MATCH() AGAINST() is based on a TF-IDF (Term Frequency - Inverse Document Frequency) algorithm:
- Term Frequency (TF): how many times the search term appears in this particular row. More occurrences = higher score.
- Inverse Document Frequency (IDF): terms that appear in many rows get lower scores (they are less distinctive). A term in only 5% of rows scores much higher than a term in 80% of rows.
- Document length normalization: the same number of occurrences in a shorter document ranks higher than in a longer one.
A score of 0 means no match. Scores are not normalized to a fixed range — they are relative to the dataset.
-- Show relevance scores for debugging and tuning
SELECT
id,
title,
ROUND(MATCH(title, body) AGAINST('mysql index performance'), 4) AS score
FROM articles
WHERE MATCH(title, body) AGAINST('mysql index performance')
ORDER BY score DESC;
-- Scores differ between natural language mode and boolean mode
SELECT
title,
MATCH(title, body) AGAINST('mysql' IN NATURAL LANGUAGE MODE) AS nl_score,
MATCH(title, body) AGAINST('+mysql' IN BOOLEAN MODE) AS bool_score
FROM articles
WHERE MATCH(title, body) AGAINST('mysql' IN NATURAL LANGUAGE MODE);Natural Language Mode
-- Explicit natural language mode
SELECT title,
MATCH(title, body) AGAINST('mysql tutorial' IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE MATCH(title, body) AGAINST('mysql tutorial' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT 10;Query Expansion Mode
Query expansion performs two searches automatically:
- A natural language search for the original terms.
- A second search that adds the most significant words found in the top results of step 1.
This broadens results to related content even if the exact search term does not appear.
-- Search for 'database' but also find articles about SQL, MySQL, schema
-- even if they do not contain the word 'database'
SELECT title
FROM articles
WHERE MATCH(title, body) AGAINST('database' WITH QUERY EXPANSION);
-- Useful for finding conceptually related articles a user might wantCombining Relevance with Recency for Ranking
Pure TF-IDF relevance scores favor articles that mention the search term most. In practice, you often want to blend relevance with how recent the content is:
-- Blend relevance score with recency using a time-decay factor
SELECT
id,
title,
created_at,
MATCH(title, body) AGAINST('mysql performance' IN BOOLEAN MODE) AS relevance,
-- Recency factor: 1.0 for today, ~0.5 for 30 days ago, decays exponentially
EXP(-0.023 * DATEDIFF(NOW(), created_at)) AS recency_factor,
-- Combined score: adjust weights to tune the balance
MATCH(title, body) AGAINST('mysql performance' IN BOOLEAN MODE)
* EXP(-0.023 * DATEDIFF(NOW(), created_at)) AS combined_score
FROM articles
WHERE MATCH(title, body) AGAINST('mysql performance' IN BOOLEAN MODE)
AND published = 1
ORDER BY combined_score DESC
LIMIT 20;ft_min_word_len and Stop Word Tuning
# Check current minimum token size SHOW VARIABLES LIKE 'innodb_ft_min_token_size'; # Default: 3 # To index 2-character words (e.g. "go", "UK", "AI"): # Add to my.cnf under [mysqld]: # innodb_ft_min_token_size = 2 # View the default stop word list SELECT * FROM information_schema.INNODB_FT_DEFAULT_STOPWORD; # Use a custom stop word file # innodb_ft_server_stopword_table = mydb/my_stopwords # (table with a single VARCHAR column named 'value')
-- After changing innodb_ft_min_token_size, rebuild ALL FULLTEXT indexes ALTER TABLE articles DROP INDEX ft_article_content; ALTER TABLE articles ADD FULLTEXT INDEX ft_article_content (title, body); -- Also run OPTIMIZE TABLE to flush the FTS cache OPTIMIZE TABLE articles;
InnoDB FULLTEXT Internals
InnoDB implements FULLTEXT indexes using several hidden auxiliary tables in the same database. Understanding this helps with troubleshooting and performance:
-- Each FULLTEXT index creates 6 auxiliary tables (FTS_*) -- They are visible in information_schema SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'FTS_%'; -- Example: FTS_000000000169_00000000016a_INDEX_1, etc. -- InnoDB adds a hidden FTS_DOC_ID column (BIGINT UNSIGNED) to every table -- with a FULLTEXT index for fast lookup SHOW CREATE TABLE articlesG -- InnoDB batches FULLTEXT index updates optimistically -- Recent inserts/updates may not be immediately searchable -- Force sync with OPTIMIZE TABLE OPTIMIZE TABLE articles; -- Check the size of FULLTEXT auxiliary tables SELECT table_name, ROUND(data_length / 1024 / 1024, 2) AS data_mb, ROUND(index_length / 1024 / 1024, 2) AS index_mb FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name LIKE 'FTS_%';
Ngram Parser for CJK and Short Words
The default FULLTEXT parser breaks text on whitespace and punctuation — it does not work for Chinese, Japanese, or Korean (CJK) text, which has no spaces between words. The ngram parser tokenizes text into overlapping n-character sequences, making it suitable for CJK and for searching short terms that the default parser would skip.
-- Check ngram token size (default: 2)
SHOW VARIABLES LIKE 'ngram_token_size';
-- Create a FULLTEXT index using the ngram parser
CREATE TABLE products_cjk (
id INT AUTO_INCREMENT PRIMARY KEY,
name_ja TEXT,
FULLTEXT INDEX ft_name (name_ja) WITH PARSER ngram
);
-- Search CJK text with ngram
SELECT * FROM products_cjk
WHERE MATCH(name_ja) AGAINST('データベース' IN BOOLEAN MODE);
-- ngram also helps search short English words like "MySQL", "AI", "JS"
-- when innodb_ft_min_token_size > 2 would otherwise exclude themFULLTEXT vs LIKE vs Elasticsearch
Factor | FULLTEXT Index | LIKE %keyword% | Elasticsearch |
|---|---|---|---|
Performance on large text | Very fast (inverted index) | Slow (full scan) | Very fast (distributed inverted index) |
Relevance ranking | Built-in TF-IDF scoring | No ranking | Advanced BM25 scoring, custom ranking |
Phrase search | Yes (Boolean mode) | Partial (careful patterns) | Yes + proximity, slop |
Fuzzy matching | No (exact tokens only) | No | Yes (edit distance) |
Synonyms | No | No | Yes (synonym token filter) |
Faceted search | Requires GROUP BY | Requires GROUP BY | Native aggregation API |
Data freshness | Near real-time (InnoDB buffer) | Always current | ~1 second default refresh |
Setup complexity | Single SQL command | None | Separate infrastructure |
Best for | App search on moderate data | Simple substring matching | Large-scale, advanced search products |
Practical E-Commerce Product Search
-- Product table with FULLTEXT on name + description + tags
CREATE TABLE products (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
tags VARCHAR(500),
price DECIMAL(10,2),
published TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FULLTEXT INDEX ft_products (name, description, tags)
);
-- Search API: rank by relevance, blend with recency
-- ? placeholder will be bound by the application
SELECT
id,
name,
price,
LEFT(description, 150) AS excerpt,
MATCH(name, description, tags) AGAINST(? IN BOOLEAN MODE) AS relevance
FROM products
WHERE published = 1
AND MATCH(name, description, tags) AGAINST(? IN BOOLEAN MODE)
ORDER BY relevance DESC
LIMIT 20 OFFSET 0;
-- Boolean search: must contain 'laptop', optionally 'gaming', exclude 'refurbished'
SELECT name, price
FROM products
WHERE published = 1
AND MATCH(name, description, tags)
AGAINST('+laptop gaming -refurbished' IN BOOLEAN MODE)
ORDER BY
MATCH(name, description, tags) AGAINST('+laptop gaming -refurbished' IN BOOLEAN MODE) DESC,
price ASC;FULLTEXT on InnoDB vs MyISAM
Feature | InnoDB | MyISAM |
|---|---|---|
Supported since | MySQL 5.6 | MySQL 3.23 |
Transactions | Yes | No |
Default min word length | 3 (innodb_ft_min_token_size) | 4 (ft_min_word_len) |
Phrase search | Yes (Boolean mode) | Yes (Boolean mode) |
Update behavior | Near real-time (batched) | Full rebuild required |
Recommended for | All new tables | Legacy only |
Limitations
FULLTEXT searches only work on TEXT, CHAR, and VARCHAR columns.
The column list in MATCH() must exactly match the FULLTEXT index definition.
You cannot use FULLTEXT in a subquery that references a different table.
Very short words (below min token size) are silently ignored — test with your actual data.
FULLTEXT indexes are larger than B-tree indexes and slow down INSERT/UPDATE/DELETE.
Natural language mode silently ignores words in more than 50% of rows.
No native fuzzy matching or synonym support — use Elasticsearch for those requirements.
Checking FULLTEXT Index Internals
-- View tokenized words in the FULLTEXT index -- (Requires setting innodb_ft_aux_table first) SET GLOBAL innodb_ft_aux_table = 'mydb/articles'; SELECT * FROM information_schema.INNODB_FT_INDEX_CACHE LIMIT 20; -- Shows: word, doc_id, position, count SELECT * FROM information_schema.INNODB_FT_INDEX_TABLE LIMIT 20; -- Shows committed (persisted) index entries -- Check FULLTEXT configuration SHOW VARIABLES LIKE 'innodb_ft%'; -- innodb_ft_min_token_size = 3 -- innodb_ft_max_token_size = 84 -- innodb_ft_enable_stopword = ON -- innodb_ft_server_stopword_table = (empty = use default)
FULLTEXT Quick Reference
Mode | Syntax | Key Behavior |
|---|---|---|
Natural Language (default) | AGAINST('mysql database') | TF-IDF relevance, stop words, 50% threshold |
Natural Language explicit | AGAINST('mysql' IN NATURAL LANGUAGE MODE) | Same as default — explicit declaration |
Boolean Mode | AGAINST('+mysql -oracle' IN BOOLEAN MODE) | Operators control inclusion/exclusion, no 50% threshold |
Query Expansion | AGAINST('database' WITH QUERY EXPANSION) | Two-pass search, finds related terms automatically |
Variable | Default | Effect |
|---|---|---|
innodb_ft_min_token_size | 3 | Words shorter than this are not indexed |
innodb_ft_max_token_size | 84 | Words longer than this are not indexed |
innodb_ft_enable_stopword | ON | Whether to filter common stop words |
innodb_ft_server_stopword_table | (empty) | Custom stop word table: schema/table format |
ngram_token_size | 2 | Token size for ngram parser (CJK/short words) |
Common FULLTEXT Pitfalls
Pitfall | Symptom | Fix |
|---|---|---|
MATCH() columns do not match index | ERROR: MATCH column list does not match any FULLTEXT index | MATCH() column list must exactly match the indexed columns |
Short words not found | Search for "go" or "AI" returns 0 results | Lower innodb_ft_min_token_size to 2 and rebuild the index |
Common word returns nothing (NL mode) | Search for 'the' returns nothing | Use Boolean mode: AGAINST("+the" IN BOOLEAN MODE) |
50% threshold in NL mode | Word appears in many rows — returns no results | Use Boolean mode: AGAINST("+word" IN BOOLEAN MODE) |
No results for exact phrase | Phrase search returns unexpected results | Use double quotes in Boolean mode: AGAINST('"primary key"' IN BOOLEAN MODE) |
Wildcard at start does not work | '*word' matches nothing | Wildcards only work at the END of a term: "word*" |
Score always 0 | MATCH() returns 0 in EXPLAIN but matches in WHERE | Use both in WHERE and SELECT: WHERE MATCH()... ORDER BY MATCH()... |
Adding FULLTEXT to an Existing Busy Table
Adding a FULLTEXT index to a large production table is a heavyweight operation that builds the entire inverted index from scratch. Plan accordingly:
-- Option 1: ALTER TABLE (rebuilds table online in MySQL 5.6+ / InnoDB) -- This acquires a metadata lock for the duration — may block writes briefly ALTER TABLE articles ADD FULLTEXT INDEX ft_content (title, body), ALGORITHM=INPLACE, LOCK=NONE; -- ALGORITHM=INPLACE allows concurrent DML on MySQL 5.7+ (for FULLTEXT, LOCK=SHARED may be needed) -- Option 2: CREATE INDEX (same as ALTER TABLE ADD FULLTEXT) CREATE FULLTEXT INDEX ft_content ON articles (title, body); -- Option 3: For very large tables, use pt-online-schema-change -- pt-online-schema-change --alter "ADD FULLTEXT INDEX ft_content (title, body)" -- D=mydb,t=articles --execute -- After adding the index, optimize to flush FTS cache to disk OPTIMIZE TABLE articles; -- Verify the index was created SHOW INDEX FROM articles WHERE Index_type = 'FULLTEXT';