Skip to content
About
Main content

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 (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

With the GIN index, how does a search for the most common word compare with the LIKE sequential scan?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    why-like-word-cannot-use-a.py49 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    why-like-word-cannot-use-a.compose.yml13 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

One million documents
QueryPlanTime (ms)Matches
LIKE '%kurta%' with a B-treesequential scan32.5378
@@ 'kurta' (rare) with GINGIN (Bitmap Index Scan)0.769378
@@ 'w0' (most common) with GINGIN (Bitmap Index Scan)44.9552K
Reference runApple M2 · 8 cores · 8 GB
Output · uv run why-like-word-cannot-use-a.py
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 matches
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · numpy 2.5.4 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What 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 or precompute for exactly that reason.