Full-text search over a pandas DataFrame

by Priya Sundaram · 20 July 2026
TL;DR. A pandas DataFrame gives you df[df.col.str.contains("x")], but that is a substring scan with no ranking, no stemming, and no relevance. To get ranked full-text search, build a small Whoosh index from df.to_dict("records"), store the row index as a unique ID field, and map search hits back to rows with df.loc[ids]. Whoosh is pure Python, so this works anywhere pandas does — no server, no compiled extensions. All code below was run against whoosh3 3.18 on Python 3.11 and the output is copied verbatim.

If your data already lives in a DataFrame, you probably reach for str.contains first. It is fine for "does this cell contain this literal substring", but it breaks down the moment you want search: it does not rank results, it will not match wireless against Wireless unless you lower-case first, it cannot match word variants (run vs running), and it searches one column at a time. Whoosh gives you a proper inverted index with BM25 ranking across several columns at once, and it stays pure Python so it drops straight into a notebook or a small service.

pip install whoosh3 pandas

1. Build an index from a DataFrame

Define a schema that mirrors the columns you care about. Make text columns TEXT (analyzed and searchable), keep numbers as NUMERIC so you can filter and sort on them, and add a unique ID field that stores the DataFrame's row label. That id is the bridge back to your rows.

import pandas as pd
from whoosh.fields import Schema, TEXT, ID, NUMERIC
from whoosh.index import create_in
import tempfile

df = pd.DataFrame([
    {"title": "Wireless headphones", "body": "Over-ear bluetooth noise cancelling", "price": 199.0},
    {"title": "USB-C cable",         "body": "Fast charging braided nylon 2m",      "price": 9.99},
    {"title": "Mechanical keyboard", "body": "Hot-swappable RGB bluetooth wireless","price": 89.5},
    {"title": "Noise machine",       "body": "White noise sleep aid",               "price": 34.0},
])

schema = Schema(
    id=ID(stored=True, unique=True),
    title=TEXT(stored=True),
    body=TEXT(stored=True),
    price=NUMERIC(stored=True, numtype=float, sortable=True),
)

ix = create_in(tempfile.mkdtemp(), schema)
writer = ix.writer()
for row_id, row in df.iterrows():
    writer.add_document(
        id=str(row_id),
        title=row["title"],
        body=row["body"],
        price=float(row["price"]),
    )
writer.commit()

ID fields are stored as strings, so cast the row label with str(...) going in and int(...) coming back out (or keep string labels if your index is not the default RangeIndex).

2. Search across multiple columns and rank by relevance

Use a MultifieldParser to search title and body together. Whoosh scores every match with BM25 by default, so the most relevant rows come first.

from whoosh.qparser import MultifieldParser
from whoosh import scoring

with ix.searcher(weighting=scoring.BM25F()) as s:
    qp = MultifieldParser(["title", "body"], schema=ix.schema)
    q = qp.parse("wireless bluetooth")
    results = s.search(q, limit=5)
    for hit in results:
        print(round(hit.score, 3), hit["id"], hit["title"], "$" + str(hit["price"]))
2.954 0 Wireless headphones $199.0
2.918 2 Mechanical keyboard $89.5

Both rows matched wireless and bluetooth even though those words are split across the title and body columns — something a single-column str.contains can't do.

3. Map hits back to DataFrame rows

The searcher returns the stored fields, but the whole point of keeping the id is to jump back to the full row (including columns you never indexed). Collect the ids and hand them to df.loc:

with ix.searcher(weighting=scoring.BM25F()) as s:
    q = MultifieldParser(["title", "body"], schema=ix.schema).parse("wireless bluetooth")
    ids = [int(hit["id"]) for hit in s.search(q, limit=5)]

matched = df.loc[ids]   # rows in relevance order
print(matched)

df.loc[ids] preserves the ranked order of ids, so your results stay sorted by relevance, not by DataFrame order.

4. Combine text search with numeric column filters

Because price is a NUMERIC field, you can filter on it with a NumericRange query and combine it with the text query — the classic "matching products under $200" pattern.

from whoosh.query import NumericRange, And

with ix.searcher(weighting=scoring.BM25F()) as s:
    text_q  = MultifieldParser(["title", "body"], schema=ix.schema).parse("wireless")
    price_q = NumericRange("price", None, 200.0)   # price <= 200
    hits = s.search(And([text_q, price_q]), limit=10)
    print([(h["id"], h["title"], h["price"]) for h in hits])

You can also skip text entirely and use Whoosh purely as a filter/sort engine over numeric columns — search(NumericRange("price", None, 200.0), sortedby="price") — but its real strength is mixing free-text relevance with structured filters in one query.

5. Keep the index in sync when the DataFrame changes

You do not have to rebuild the whole index every time a row changes. Because id is unique=True, update_document atomically replaces the matching document (delete + add), so edits don't create duplicates:

writer = ix.writer()
writer.update_document(id="0", title="Wireless headphones (v2)", body="...", price=149.0)
writer.commit()

with ix.searcher() as s:
    print(s.doc_count())   # -> 1 for that id, not 2

Verified output: after updating row 0 the searcher returns the new title and price, and doc_count() stays at the expected value instead of doubling. For deletes, use writer.delete_by_term("id", "0"). For a large batch of edits, collect the changed row ids and loop over update_document inside a single writer/commit.

When to use this (and when not to)

The same to_dict("records") / unique-id pattern works for any tabular source — a CSV, a SQL query result, or a list of dicts from an API. See the analyzer recipes to control tokenization per column (accent folding, stemming, n-grams), and fuzzy search for typo-tolerant matching.

Whoosh is maintained again. It's pure Python, zero runtime dependencies, BSD-2-Clause, with a live browser demo and modern packaging.

⭐ Star it on GitHub  ·  pip install whoosh3  ·  Try the live demo