Why LIKE '%word%' cannot use a B-tree, and what an inverted index does instead
Run a real systemNeeds Docker, Python
Problem
A B-tree on a text column is sorted by the whole string, so it can find values with a known prefix and nothing else: LIKE '%kurta%' has no prefix, and the planner falls back to reading every row. An inverted index (GIN on a tsvector) maps each word to the documents that contain it, so a word search reads only that word's posting list. Its cost then follows the word's frequency: a rare word is a short list, a common word is most of the table.
Create a table with one million short text rows, with word frequencies Zipfian (probability proportional to 1/rank). Create a B-tree on the text and run EXPLAIN ANALYZE SELECT count(*) FROM docs WHERE body LIKE '%kurta%'. Create a GIN index on to_tsvector(body) and run the same search with @@. Then query the most frequent word.
The question. Which plan does each query get, how long does it take, and how does word frequency change the cost with the index?
Predict
The first plan is a sequential scan (the B-tree is ignored because the pattern has no fixed prefix) and takes seconds. The second plan uses the inverted (GIN) index and takes milliseconds for a rare word. A very common word is much slower than a rare word even with the index. That gap is variance costing more than volume.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
why-like-word-cannot-use-a.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]", "numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Why LIKE '%word%' cannot use a B-tree. One million short documents in PostgreSQL, words drawn with Zipfian frequencies. A B-tree on the text and LIKE '%word%' for a rare word; then a GIN full-text index and the same word with @@; then the most common word with the GIN index. Needs the PostgreSQL service from the compose file (port 56001). """ import argparse, json, re import numpy as np, psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:56001/postgres", help="Connection string.") p.add_argument("--docs", type=int, default=1_000_000, help="Documents.") p.add_argument("--vocab", type=int, default=20_000, help="Vocabulary size.") p.add_argument("--words", type=int, default=8, help="Words per document.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(0) vocab = np.array([f"w{i}" for i in range(args.vocab)]); vocab[2000] = "kurta" # a rare word with a fixed name prob = 1 / np.arange(1, args.vocab + 1); prob /= prob.sum() idx = rng.choice(args.vocab, (args.docs, args.words), p=prob) c = psycopg.connect(args.dsn, autocommit=True) c.execute("DROP TABLE IF EXISTS docs"); c.execute("CREATE TABLE docs (id serial PRIMARY KEY, body text)") with c.cursor().copy("COPY docs (body) FROM STDIN") as cp: for row in idx: cp.write_row((" ".join(vocab[row]),)) c.execute("CREATE INDEX docs_btree ON docs (body)"); c.execute("ANALYZE docs") def run(sql): txt = "\n".join(r[0] for r in c.execute("EXPLAIN (ANALYZE) " + sql).fetchall()) node = "GIN (Bitmap Index Scan)" if "Bitmap Index Scan" in txt else "B-tree index" if "Index" in txt else "sequential scan" return node, float(re.search(r"Execution Time: ([\d.]+)", txt).group(1)), c.execute(sql).fetchone()[0] rows = [] n, t, k = run("SELECT count(*) FROM docs WHERE body LIKE '%kurta%'"); rows.append({"query": "LIKE '%kurta%' with a B-tree", "plan": n, "ms": t, "matches": k}) c.execute("CREATE INDEX docs_gin ON docs USING gin (to_tsvector('simple', body))"); c.execute("ANALYZE docs") n, t, k = run("SELECT count(*) FROM docs WHERE to_tsvector('simple', body) @@ to_tsquery('simple', 'kurta')"); rows.append({"query": "@@ 'kurta' (rare) with GIN", "plan": n, "ms": t, "matches": k}) n, t, k = run("SELECT count(*) FROM docs WHERE to_tsvector('simple', body) @@ to_tsquery('simple', 'w0')"); rows.append({"query": "@@ 'w0' (most common) with GIN", "plan": n, "ms": t, "matches": k}) for r in rows: print(f"{r['query']:<32} {r['plan']:<25} {r['ms']:9.1f} ms {r['matches']:>9,} matches") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"queries": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
why-like-word-cannot-use-a.compose.yml # PostgreSQL for the why-like-word-cannot-use-a lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "shared_buffers=512MB", "-c", "work_mem=64MB"] environment: POSTGRES_PASSWORD: lab ports: ["56001:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f why-like-word-cannot-use-a.compose.yml up -d --wait uv run why-like-word-cannot-use-a.py uv run why-like-word-cannot-use-a.py --help # every knob docker compose -f why-like-word-cannot-use-a.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:56001/postgres | Connection string. |
| --docs1,000,000 | Documents. |
| --vocab20,000 | Vocabulary size. |
| --words8 | Words per document. |
Our run
One million documents
| Query | Plan | Time (ms) | Matches |
|---|---|---|---|
| LIKE '%kurta%' with a B-tree | sequential scan | 32.5 | 378 |
| @@ 'kurta' (rare) with GIN | GIN (Bitmap Index Scan) | 0.769 | 378 |
| @@ 'w0' (most common) with GIN | GIN (Bitmap Index Scan) | 44.9 | 552K |
LIKE '%kurta%' with a B-tree sequential scan 32.5 ms 378 matches
@@ 'kurta' (rare) with GIN GIN (Bitmap Index Scan) 0.8 ms 378 matches
@@ 'w0' (most common) with GIN GIN (Bitmap Index Scan) 44.9 ms 551,892 matchesWhat we found
LIKE '%kurta%' ignored the B-tree and ran a sequential scan in 32.5 ms, faster than "seconds" because a million short rows fit in memory. The GIN index found the rare word in 0.769 ms. The most common word, in 552K documents, took 44.9 ms with the same index: longer than the sequential scan of the LIKE query.
Read it
An index helps only queries whose shape it matches: a B-tree serves prefixes and ranges, an inverted index serves words. And an inverted index's cost is the length of the lists it reads, so the same index is fast for rare words and slow for common ones; search engines skip stop words or precompute for exactly that reason.