Full-Text Index #

Almost every application has a text search feature — searching products by name and description, searching articles by content, searching users by name and bio. The first solution developers almost always choose is LIKE '%keyword%'. It’s simple, works right away, and needs no configuration. The problem: LIKE '%keyword%' always does a full table scan — no index can help with a leading wildcard. On small tables this isn’t felt, but on tables with hundreds of thousands of rows, text search becomes a very real bottleneck. A Full-Text Index (FTI) is the right solution for this problem, but it has characteristics, limitations, and trade-offs to understand before using it. This article covers how FTIs work, the implementation differences in MySQL and PostgreSQL, how to combine them with regular filters for optimal performance, and when an FTI is no longer enough.

Why LIKE ‘%keyword%’ Always Fails at Scale #

Before discussing the solution, it’s important to understand concretely why LIKE '%keyword%' is fundamentally problematic.

-- A search query that looks reasonable
SELECT id, title, content FROM articles WHERE content LIKE '%database%';

-- Its EXPLAIN:
-- +------+------+------+----------+-------------+
-- | type | key  | rows | filtered | Extra       |
-- +------+------+------+----------+-------------+
-- | ALL  | NULL | 850000 | 11.11 | Using where |
-- +------+------+------+----------+-------------+
-- type = ALL → full table scan
-- rows = 850,000 → every row must be read

-- Why can't an index help?
-- A B-Tree index is sorted by the string's starting values.
-- An index can help WHERE content LIKE 'database%' (fixed prefix)
-- But CANNOT help WHERE content LIKE '%database%'
-- because the database doesn't know where in the string that word appears.

For a table with 1 million articles and ~2KB of content per row:

The cost of LIKE '%keyword%' on 1 million articles:
──────────────────────────────────────────────────────────────
  Data that must be read: 1,000,000 × 2KB = ~2GB
  Every row: character-by-character scan looking for 'database'
  Typical time: 5-15 seconds on a decent server

  If 100 users search simultaneously:
    → The database reads 200GB of data per minute just for search
    → CPU explodes, disk I/O saturates
    → The whole system slows down
──────────────────────────────────────────────────────────────

How the Inverted Index Works: The FTI Foundation #

A Full-Text Index uses a completely different structure from a B-Tree — called an inverted index. Instead of mapping rows to content, an inverted index maps each word to the list of rows containing that word.

How the database builds an inverted index:
──────────────────────────────────────────────────────────────
  Original data:
    Row 1: "Panduan optimasi database PostgreSQL" (Guide to PostgreSQL database optimization)
    Row 2: "Index B-Tree dan Full-Text Index di MySQL" (B-Tree index and Full-Text Index in MySQL)
    Row 3: "PostgreSQL vs MySQL: perbandingan performa database" (PostgreSQL vs MySQL: database performance comparison)

  Tokenization process (the tokenizer splits text into words):
    Row 1 → ["guide", "postgresql", "database", "optimization"]
    Row 2 → ["b-tree", "index", "full-text", "index", "mysql"]
    Row 3 → ["postgresql", "mysql", "database", "performance", "comparison"]

  Stemming (optional — reduction to the base form):
    "optimization" → "optim"
    "comparison" → "compar"  (depends on the language)

  The resulting inverted index:
    "database"    → [1, 3]
    "postgresql"  → [1, 3]
    "mysql"       → [2, 3]
    "index"       → [2]
    "performance" → [3]
    ...

  Query: MATCH AGAINST('database mysql')
    → Look up "database" → [1, 3]
    → Look up "mysql"    → [2, 3]
    → Union: [1, 2, 3] (NATURAL LANGUAGE) or
      Intersect: [3] (BOOLEAN AND)
    → Calculate a relevance score per row
    → Sort by score
──────────────────────────────────────────────────────────────
  No need to read article contents at all to search.
  Just a lookup in the inverted index → O(1) per word.

Full-Text Indexes in MySQL #

Creating a Full-Text Index #

-- On a new table
CREATE TABLE articles (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    title      VARCHAR(500) NOT NULL,
    content    MEDIUMTEXT NOT NULL,
    status     VARCHAR(20) NOT NULL DEFAULT 'draft',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_articles_status (status),
    FULLTEXT INDEX ft_articles_search (title, content)  -- the FTI covers two columns
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Adding an FTI to an existing table
ALTER TABLE articles
ADD FULLTEXT INDEX ft_articles_search (title, content);

-- An FTI can cover several columns — the MATCH query must name all columns
-- in the index, not just some of them

NATURAL LANGUAGE MODE vs BOOLEAN MODE #

MySQL supports two search modes with very different behaviors:

-- NATURAL LANGUAGE MODE (default): searches words, sorts by relevance
SELECT id, title,
       MATCH(title, content) AGAINST('database optimization' IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE MATCH(title, content) AGAINST('database optimization' IN NATURAL LANGUAGE MODE)
  AND status = 'published'
ORDER BY score DESC
LIMIT 20;
-- Fits: general search engines, "find the most relevant articles"
-- Weakness: overly common words (appearing in > 50% of rows) are automatically ignored

-- BOOLEAN MODE: explicit control with operators
SELECT id, title
FROM articles
WHERE MATCH(title, content) AGAINST(
    '+database +optimization -beginner'
    IN BOOLEAN MODE
)
  AND status = 'published'
LIMIT 20;
-- + means the word MUST be present
-- - means the word must NOT be present
-- No prefix: optional (increases relevance if present)
-- "phrase" (in quotes): must appear as a sequential phrase
-- Fits: precise filters, searches with exclusions

-- A complete BOOLEAN MODE operator example:
SELECT id, title FROM articles
WHERE MATCH(title, content) AGAINST(
    '+postgresql +"query optimization" -beginner tutorial*'
    IN BOOLEAN MODE
);
-- postgresql   : must be present
-- "query optimization" : this phrase must appear in sequence
-- -beginner    : don't show beginner articles
-- tutorial*    : wildcard — matches "tutorial", "tutorials", etc.
NATURAL LANGUAGE vs BOOLEAN MODE comparison:
──────────────────────────────────────────────────────────────────
  Aspect              │ NATURAL LANGUAGE    │ BOOLEAN MODE
──────────────────────────────────────────────────────────────────
  Relevance score     │ Automatically       │ Not calculated (no ranking)
  Required words      │ Not possible        │ + operator
  Excluded words      │ Not possible        │ - operator
  Words in > 50% of rows│ Automatically ignored│ Still processed
  Wildcards           │ Not supported       │ * at the end of words
  Exact phrases       │ Not supported       │ "phrase in quotes"
  Best for            │ General search      │ Precise filters
──────────────────────────────────────────────────────────────────

MySQL’s Built-in Limitations You Must Know #

MySQL FTIs have several non-intuitive limitations that often cause confusing results:

-- LIMITATION 1: Minimum word length (default: 3 characters for InnoDB)
-- Short words aren't indexed!
SELECT @@innodb_ft_min_token_size;  -- default: 3
-- "go", "db", "id" won't be indexed or searchable

-- Change it if you need short-word support:
SET GLOBAL innodb_ft_min_token_size = 2;
-- After changing: REBUILD all fulltext indexes!
-- ALTER TABLE articles DROP INDEX ft_articles_search;
-- ALTER TABLE articles ADD FULLTEXT INDEX ft_articles_search (title, content);

-- LIMITATION 2: Stopwords — words always ignored
-- MySQL has a built-in stopword list: "the", "a", "is", "in", "at", etc.
SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD;
-- "about", "are", "as", "at", "be", "by", "com", "for", "from", ...

-- Consequence: searching "how to use database" only searches "database"
-- "how", "to", "use" are stopwords!

-- Disable stopwords for technical contexts:
SET GLOBAL innodb_ft_enable_stopword = 0;
-- Or use a custom stopword list

-- LIMITATION 3: Words appearing in > 50% of rows are ignored in NATURAL LANGUAGE
-- If 60% of articles contain the word "mysql", that word is useless for relevance
-- Use BOOLEAN MODE to override this behavior

-- LIMITATION 4: A minimum of 3 rows of data
-- An FTI won't return results if the table has < 3 rows
-- This often causes confusion in test environments with minimal data
After changing innodb_ft_min_token_size or other FTI configuration, you must rebuild all FULLTEXT indexes — configuration changes aren’t automatically applied to existing indexes. This must be done in a maintenance window because the rebuild can take a long time on large tables.

Full-Text Indexes in PostgreSQL #

PostgreSQL takes a more flexible approach with the tsvector data type and the @@ operator.

Setup and Basic Queries #

-- Approach 1: Full-Text Index using GIN (Generalized Inverted Index)
-- GIN fits documents — fast queries but slower updates
CREATE INDEX idx_articles_fts
ON articles USING GIN(to_tsvector('english', title || ' ' || content));

-- Approach 2: GIST index — faster updates but slightly slower queries
CREATE INDEX idx_articles_fts_gist
ON articles USING GIST(to_tsvector('english', title || ' ' || content));

-- Query with to_tsvector + to_tsquery
SELECT id, title,
       ts_rank(
           to_tsvector('english', title || ' ' || content),
           to_tsquery('english', 'database & optimization')
       ) AS rank
FROM articles
WHERE to_tsvector('english', title || ' ' || content)
      @@ to_tsquery('english', 'database & optimization')
  AND status = 'published'
ORDER BY rank DESC
LIMIT 20;

Using Generated Columns for Better Performance #

Instead of computing to_tsvector on every query, store the result in a generated column:

-- Add a tsvector column updated automatically
ALTER TABLE articles
ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
        setweight(to_tsvector('english', COALESCE(content, '')), 'B')
    ) STORED;
-- setweight 'A' for title (higher weight)
-- setweight 'B' for content (lower weight)

-- Create a GIN index on the generated column
CREATE INDEX idx_articles_search_vector
ON articles USING GIN(search_vector);

-- The query is now much cleaner and faster
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'database & optimization') query
WHERE search_vector @@ query
  AND status = 'published'
ORDER BY rank DESC
LIMIT 20;
-- search_vector is already stored → no need to compute to_tsvector per query

PostgreSQL Search Operators #

-- to_tsquery: words separated by logical operators
to_tsquery('english', 'database & optimization')  -- AND: both words must be present
to_tsquery('english', 'database | mysql')          -- OR: either or both
to_tsquery('english', 'database & !beginner')      -- NOT: database but not beginner
to_tsquery('english', 'optim:*')                   -- prefix: optimization, optimistic, etc.

-- plainto_tsquery: free input, no explicit operators needed
plainto_tsquery('english', 'database optimization tutorial')
-- automatically interpreted as AND

-- phraseto_tsquery: sequential phrase search
phraseto_tsquery('english', 'query optimization')
-- searches for "query" directly followed by "optimization"

-- websearch_to_tsquery: Google-Search-like syntax
websearch_to_tsquery('english', 'database optimization -beginner "index scan"')
-- More user-friendly for search box input

Combining FTIs with B-Tree Filters: The Performance Key #

A Full-Text Index used without additional filters returns too many results and puts excessive pressure on scoring. Always combine it with regular B-Tree filters to shrink the search space first.

The Correct Pattern #

-- ANTI-PATTERN: an FTI without additional filters
SELECT id, title, content
FROM articles
WHERE MATCH(title, content) AGAINST('database' IN NATURAL LANGUAGE MODE);
-- Might return 100,000 rows for a common word
-- Scoring and ranking 100,000 rows is very expensive

-- CORRECT: an FTI combined with B-Tree filters
SELECT id, title,
       MATCH(title, content) AGAINST('database optimization' IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE status = 'published'          -- B-Tree filter: index on status
  AND category_id IN (1, 5, 12)     -- B-Tree filter: index on category_id
  AND created_at >= '2025-01-01'    -- B-Tree filter: index on created_at
  AND MATCH(title, content) AGAINST('database optimization' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT 20;
-- The database first filters with B-Trees (maybe leaving 5,000 relevant articles)
-- Then the FTI runs on those 5,000 articles, not the entire table

Understanding the Filter Order in the Query Planner #

MySQL and PostgreSQL query planners are usually smart enough to choose the optimal filter order, but there are a few things to watch:

-- In MySQL: check whether the FTI or B-Tree is used first
EXPLAIN SELECT id, title
FROM articles
WHERE status = 'published'
  AND MATCH(title, content) AGAINST('database' IN BOOLEAN MODE);

-- Expected output:
-- +------+-------+--------------------------+-------+------+---------------------------+
-- | type | key   | key_len                  | rows  | Extra                       |
-- +------+-------+--------------------------+-------+------+---------------------------+
-- | fulltext | ft_articles_search | 0 | 1 | Using where; Ft_hints: no_ranking |
-- +------+-------+--------------------------+-------+------+---------------------------+

-- If MySQL chooses the FTI alone and ignores the status index:
-- Use FORCE INDEX to force the order
SELECT id, title
FROM articles USE INDEX (idx_articles_status, ft_articles_search)
WHERE status = 'published'
  AND MATCH(title, content) AGAINST('database' IN BOOLEAN MODE);

Measuring Search Result Quality #

Unlike regular queries whose results are definitively right or wrong, full-text search has a quality dimension (relevance). It’s important to measure whether the returned results are actually relevant to the search intent.

-- MySQL: show the relevance score for debugging
SELECT
    id,
    title,
    MATCH(title, content) AGAINST('database optimization' IN NATURAL LANGUAGE MODE) AS relevance_score
FROM articles
WHERE MATCH(title, content) AGAINST('database optimization' IN NATURAL LANGUAGE MODE)
  AND status = 'published'
ORDER BY relevance_score DESC
LIMIT 10;

-- A high score = more relevant
-- If all scores are very low (< 0.1): the word may be too common or absent from the index

-- PostgreSQL: show a headline (snippet with highlighting)
SELECT
    id,
    title,
    ts_rank(search_vector, query) AS rank,
    ts_headline('english', content, query,
        'MaxWords=50, MinWords=20, ShortWord=3, HighlightAll=FALSE'
    ) AS snippet
FROM articles, to_tsquery('english', 'database & optimization') query
WHERE search_vector @@ query
  AND status = 'published'
ORDER BY rank DESC
LIMIT 10;
-- ts_headline produces a content snippet with highlighted keywords
-- Useful for showing search result previews to users

When an In-Database FTI Is No Longer Enough #

A database’s built-in Full-Text Index is an excellent solution for basic to intermediate search needs. But there’s a point where it’s no longer sufficient, and migrating to a dedicated search engine should be considered.

Keep using the built-in database FTI if:
──────────────────────────────────────────────────────────────
  ✓ Data volume < 10 million documents
  ✓ Simple search queries (keywords, phrases)
  ✓ No advanced features needed (fuzzy matching, autocomplete, faceting)
  ✓ Small team, no operational capacity for extra infrastructure
  ✓ Languages supported by the built-in tokenizer (English, some European)
  ✓ Search isn't a core feature — just an additional filter

Consider Elasticsearch/OpenSearch if:
──────────────────────────────────────────────────────────────
  ✗ Volume > 10 million documents and still growing
  ✗ Need fuzzy matching ("databse" → "database")
  ✗ Need autocomplete / search-as-you-type
  ✗ Need faceting (dynamic filters: "1,234 results for category X")
  ✗ Need good multi-language support (including Indonesian)
  ✗ Need accurate highlighting in result snippets
  ✗ Search is a core, business-critical feature
  ✗ Need complex custom scoring / ranking

Consider Meilisearch/Typesense if:
──────────────────────────────────────────────────────────────
  ✗ Need fuzzy matching out of the box but don't want complex ES setup
  ✗ Simple needs but requiring a better search experience than a DB
  ✗ Small team needing a simple solution

Architecture Pattern with an External Search Engine #

The database → search engine sync pattern:

flowchart TD
    subgraph WP["Write Path"]
        direction TB
        ReqW["Request"] --> AppW["Application"]
        AppW --> MySQLW["MySQL (source of truth)"]
        AppW --> QueueW["Event Bus / Queue"]
        QueueW --> WorkerW["Search Indexer Worker"]
        WorkerW --> ESW["Elasticsearch / Meilisearch"]
    end

    subgraph RP["Read Path"]
        direction TB
        ReqR["Search request"] --> AppR["Application"]
        AppR --> ESR["Elasticsearch"]
        ESR -->|"document IDs"| MySQLR["MySQL (fetch full data if needed)"]
    end

The database remains the source of truth. The search engine is only a read replica specialized for search.


Anti-Patterns to Avoid #

-- ✗ Anti-pattern 1: LIKE '%keyword%' for searches on large tables
SELECT * FROM articles WHERE content LIKE '%database%';
-- → Full table scan, indexes useless
-- ✓ Solution: use a FULLTEXT INDEX with MATCH ... AGAINST

-- ✗ Anti-pattern 2: FTIs without B-Tree filters
SELECT * FROM articles
WHERE MATCH(title, content) AGAINST('database');
-- → Can return hundreds of thousands of rows for common words
-- ✓ Solution: always add WHERE status = ? or other filters before the FTI

-- ✗ Anti-pattern 3: ORDER BY MATCH ... AGAINST without LIMIT
SELECT *, MATCH(title, content) AGAINST('database') AS score
FROM articles
WHERE MATCH(title, content) AGAINST('database')
ORDER BY score DESC;
-- → Compute scores for every matching row, sort them all — very expensive
-- ✓ Solution: always add LIMIT 20 or pagination

-- ✗ Anti-pattern 4: FULLTEXT INDEXes on frequently updated columns
-- E.g. indexes on notes, last_comment, activity_log columns
-- → Every UPDATE to those columns rebuilds part of the inverted index
-- ✓ Solution: FTIs only for rarely changed columns (main title, content)

-- ✗ Anti-pattern 5: searching short words without changing the min token size
-- MATCH(title) AGAINST('go' IN BOOLEAN MODE) → returns no results
-- because "go" (2 characters) is below the default minimum token size (3)
-- ✓ Solution: change innodb_ft_min_token_size = 2 and rebuild the index

-- ✗ Anti-pattern 6: not handling stopwords in NATURAL LANGUAGE MODE
-- A user searches "how to use index" → only "index" is searched
-- "how", "to", "use" are stopwords → users wonder why results are so few
-- ✓ Solution: use BOOLEAN MODE for queries needing precision,
--   or disable the stopword list, or use an external search engine

Full-Text Index Checklist #

SETUP:
  □ Does the FULLTEXT INDEX cover the right columns (title, content — not every column)?
  □ Are the indexed columns rarely updated?
  □ For Indonesian text columns: consider an external search engine
    (built-in FTIs don't have a good Indonesian tokenizer)?
  □ In MySQL: is innodb_ft_min_token_size checked? (default 3 — 2-character words aren't indexed)
  □ In PostgreSQL: is a generated tsvector column created for optimal performance?

QUERIES:
  □ Is MATCH ... AGAINST always combined with B-Tree filters (status, category, etc.)?
  □ Is there a LIMIT on every FTI query?
  □ No LIKE '%keyword%' on large tables?
  □ Is the mode chosen for the need (NATURAL LANGUAGE vs BOOLEAN)?
  □ In PostgreSQL: is websearch_to_tsquery used for user input
    (safer against injection than to_tsquery)?

MONITORING:
  □ Is there an alert when FTI queries take longer than a threshold (e.g. > 500ms)?
  □ Is the index size monitored? (FTIs can be bigger than the main table)
  □ Is write latency monitored after adding the FTI?

Summary #

  • LIKE '%keyword%' is always a full table scan — a leading wildcard makes B-Tree indexes completely useless. For text searches on large tables, this is a query to eliminate.
  • FTIs use inverted indexes — mapping each word to the list of rows containing it. Word lookups become O(1) without reading any document contents.
  • NATURAL LANGUAGE and BOOLEAN MODE are fundamentally different — NATURAL LANGUAGE computes relevance automatically but ignores common words (> 50% of rows). BOOLEAN MODE gives full control with +, -, * operators and quoted phrases.
  • MySQL’s minimum word length (default 3) often surprises — 2-character words like “go”, “db”, “id” aren’t indexed and can’t be searched. Change innodb_ft_min_token_size = 2 and rebuild the index if needed.
  • FTIs must be combined with B-Tree filters — an FTI without filters can return hundreds of thousands of rows for common words. Filter WHERE status = ? AND category_id = ? first with B-Trees, then apply the FTI to the much smaller subset.
  • PostgreSQL’s generated tsvector columns are more efficient — instead of computing to_tsvector() on every query, store the result in a generated STORED column with a GIN index. Queries become clean and fast.
  • In-database FTIs fit up to ~10 million documents — for more complex needs (fuzzy matching, autocomplete, Indonesian, faceting), Elasticsearch/Meilisearch is the better choice.
  • FTIs add write overhead — every INSERT or UPDATE on an indexed column triggers re-tokenization and inverted index updates. Apply FTIs only to rarely changed columns.

← Previous: Bulk CUD Operation   Next: Avoid ORM →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact