Trigram Indexes for LIKE and ILIKE #
A customer-lookup box runs WHERE name ILIKE '%hans%'. On 40 million customers it scans the table every keystroke. There is an index on name, and it cannot help: a B-tree orders strings from the left, and a search that may match anywhere in the string has no starting point in that order.
Access-method selection is covered in specialized index types (GIN/GiST). This page covers the extension that makes substring search indexable.
The Index Condition #
pg_trgm decomposes text into trigrams — overlapping three-character sequences, with padding at word boundaries. hans becomes h, ha, han, ans, ns . A search string is decomposed the same way, and the index finds rows sharing enough trigrams, after which the executor rechecks the original condition.
That makes these indexable:
LIKE '%term%'andILIKE '%term%'— the pattern’s literal parts become trigrams;~and~*regular expressions, for the parts of the pattern that yield trigrams;- similarity operators
%and<->withsimilarity()andword_similarity()ranking.
Two operator classes:
gin_trgm_ops— usually the better choice for search: smaller than the GiST equivalent for most text, and faster for lookups. No ordering support.gist_trgm_ops— supports distance ordering (ORDER BY name <-> 'hans' LIMIT 10), so it fits “closest matches” rankings; generally slower and larger for plain filtering.
What trigrams cannot do: help patterns whose literal part is shorter than three characters (LIKE '%an%' yields no usable trigram), and they are lossy, so every candidate row is rechecked — visible as a Recheck Cond and, for GiST, sometimes many removed rows.
When the pattern is anchored at the start — LIKE 'hans%' — a trigram index is unnecessary: a B-tree with text_pattern_ops (or a database in the C collation) serves prefix searches directly, and lower(name) text_pattern_ops handles the case-insensitive variant.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name FROM customers WHERE name ILIKE '%hans%' LIMIT 20;
Before:
Limit (actual time=0.4..4120.6 rows=20 loops=1)
-> Seq Scan on customers (actual rows=20 loops=1)
Filter: (name ~~* '%hans%'::text)
Rows Removed by Filter: 8204110
Buffers: shared hit=41022 read=182044
Execution Time: 4120.9 ms
After:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY customers_name_trgm_idx ON customers USING gin (name gin_trgm_ops);
Limit (actual time=8.2..8.4 rows=20 loops=1)
-> Bitmap Heap Scan on customers (actual rows=20 loops=1)
Recheck Cond: (name ~~* '%hans%'::text)
Rows Removed by Index Recheck: 112
Heap Blocks: exact=1840
-> Bitmap Index Scan on customers_name_trgm_idx (actual time=6.1..6.1 rows=1952 loops=1)
Index Cond: (name ~~* '%hans%'::text)
Execution Time: 8.5 ms
-- 1,952 candidates from trigrams; 112 rejected by the recheck
A short pattern, where trigrams cannot help:
EXPLAIN (ANALYZE) SELECT id FROM customers WHERE name ILIKE '%an%' LIMIT 20;
Limit → Seq Scan on customers Filter: (name ~~* '%an%'::text)
-- two literal characters: no trigram can be extracted, so the index is unusable
Step-by-Step Resolution #
-
Classify the patterns your application sends. Prefix, substring, case-insensitive, regular expression, or ranked similarity — each has a different answer.
-
For prefix-only searches, avoid the extension:
CREATE INDEX CONCURRENTLY customers_name_prefix_idx ON customers (lower(name) text_pattern_ops); -- query: WHERE lower(name) LIKE lower('hans') || '%' -
For substring or case-insensitive searches, create a GIN trigram index.
ILIKEworks directly; there is no need to indexlower(name)as well. -
For ranked “closest match” results, use GiST and order by distance:
CREATE INDEX CONCURRENTLY customers_name_trgm_gist ON customers USING gist (name gist_trgm_ops); SELECT name FROM customers ORDER BY name <-> 'hans' LIMIT 10; -
Enforce a minimum search length in the application — three characters — so short patterns never reach the database as unindexable scans.
-
Tune the similarity threshold when using
%:SET pg_trgm.similarity_threshold = 0.4trades recall for candidate count. -
Check size and write cost. Trigram indexes are large; on ingest-heavy tables, consider indexing only the rows that are searchable with a partial index.
Before and After #
-- BEFORE: ILIKE '%hans%' with no trigram index
Seq Scan on customers Rows Removed by Filter: 8204110 Buffers: read=182044 Execution Time: 4120.9 ms
-- AFTER: gin (name gin_trgm_ops)
Bitmap Heap Scan Recheck Cond: (name ~~* '%hans%') Heap Blocks: exact=1840 Execution Time: 8.5 ms
Trigrams versus full-text search #
Trigram search matches character sequences; full-text search matches words after stemming and stop-word removal. They answer different questions. “Find the customer whose name contains this fragment, including misspellings” is a trigram problem. “Find documents about billing errors” is a full-text problem, and a tsvector column with a GIN index is both smaller and more accurate for it.
The two can coexist: a support system might use full-text search on ticket bodies and trigram search on customer names in the same schema. What does not work well is using trigrams as a general document search, because index size grows with text length and candidate sets become large, making the recheck step dominant.
For multi-column searches — name, email and phone in one box — index each column separately and combine with OR, letting the planner build a BitmapOr, as described in BitmapOr and BitmapAnd plans. A single index on a concatenation is tempting but loses the ability to search one field precisely.
Keeping the candidate set small #
A trigram search’s cost has two parts: finding candidates in the index, and rechecking them against the heap. The recheck is where a badly chosen search degrades. A term made of common trigrams — son in a table of Scandinavian surnames — produces hundreds of thousands of candidates, and the plan spends its time reading heap pages only to reject most of them. The plan shows this as a large Rows Removed by Index Recheck next to a small final row count.
Three mitigations help. Combine the trigram condition with a selective equality where one exists, so the bitmap intersects with a smaller set. Add LIMIT and let the bitmap heap scan stop early, which works well for type-ahead search. And where the application searches structured fields, prefer an exact or prefix condition when the user’s input looks like one — an email fragment containing @, or a numeric customer id — reserving substring search for genuinely fuzzy input.
Common Pitfalls #
Short search terms. Fewer than three characters yield no trigrams. Diagnostic signal: sequential scans for short queries only. Fix: enforce a minimum length in the UI.
Indexing lower(name) for ILIKE. Unnecessary with trigrams, which handle case-insensitive matching. Diagnostic signal: duplicate trigram indexes. Fix: one index on the column.
Choosing GiST for filtering. It is slower and larger than GIN for plain matching. Diagnostic signal: high recheck counts and long index scans. Fix: GIN unless distance ordering is needed.
Trigram indexes on huge text columns. Index size explodes with document length. Diagnostic signal: an index several times the table size. Fix: full-text search for documents.
Frequently Asked Questions #
How do I make LIKE ‘%text%’ use an index in PostgreSQL? #
Install the pg_trgm extension and create a GIN index with gin_trgm_ops on the column. Trigram matching turns substring and case-insensitive patterns into index conditions, with a recheck against the heap.
Should I use GIN or GiST for trigram indexes? #
GIN for filtering: it is usually smaller and faster for matching. GiST when you need distance ordering, such as ORDER BY column <-> ‘term’ for ranked nearest matches.
Why does my trigram index not help short search terms? #
Trigrams are three characters long, so a pattern with fewer than three literal characters produces none and the index cannot be used. Require at least three characters before querying.
Related #
- Specialized Index Types (GIN/GiST) — parent guide: access methods
- jsonb_path_ops vs jsonb_ops for GIN Indexes — sibling: the other GIN operator-class choice
- Function-Wrapped Columns and Sargability — why lower() and patterns block plain indexes