You're building a FastAPI service
and you want a /search endpoint that actually ranks results
instead of running a slow LIKE '%term%' scan. You don't want to
stand up Elasticsearch, you don't want a C extension in your container image,
and you'd like the same code to run on your laptop, in CI, and in production.
That's exactly what Whoosh
is for: a full-text search library written entirely in Python. No search server,
no compiler, no native dependencies — just pip install whoosh3. In
this guide we'll add ranked, highlighted search to a small FastAPI app, and cover
the one gotcha that trips people up: Whoosh's API is synchronous, so we'll look at
how to use it safely from async routes.
1. Install
pip install whoosh3 fastapi "uvicorn[standard]"
The importable package is whoosh; the PyPI distribution is
whoosh3 (the actively
maintained fork). Whoosh has zero runtime dependencies of its
own, so it adds essentially nothing to your image.
2. Define the index schema
A Whoosh index is just a directory of files. Design the schema once: which
fields exist, which are searchable, and which you store so you can show them back
in the API response. We give title a boost so title matches
outrank body matches.
# search_index.py
from pathlib import Path
from whoosh import index
from whoosh.fields import Schema, ID, TEXT
INDEX_DIR = Path("index_dir")
SCHEMA = Schema(
id=ID(stored=True, unique=True),
title=TEXT(stored=True, field_boost=2.0),
body=TEXT(stored=True),
)
def get_index():
"""Open the index, creating it on first run."""
if not INDEX_DIR.exists():
INDEX_DIR.mkdir(parents=True)
return index.create_in(INDEX_DIR, SCHEMA)
if not index.exists_in(INDEX_DIR):
return index.create_in(INDEX_DIR, SCHEMA)
return index.open_dir(INDEX_DIR)
Because id is unique=True, re-indexing a document
with the same id updates it in place instead of creating a duplicate — handy when
your source data changes.
3. Index some documents
Whoosh opens one writer at a time and it takes an exclusive lock, so it's
cleanest to build the index from a small script (or a startup task). Use
update_document so re-runs are idempotent.
# build_index.py
from search_index import get_index
DOCS = [
{"id": "1", "title": "Getting started with FastAPI",
"body": "FastAPI is a modern, fast Python web framework for building APIs with type hints."},
{"id": "2", "title": "Full-text search in Python",
"body": "Whoosh is a pure-Python search library with BM25 ranking and highlighting."},
{"id": "3", "title": "Async endpoints and concurrency",
"body": "Use async def for concurrent request handling, and run blocking work in a threadpool."},
]
ix = get_index()
writer = ix.writer()
for doc in DOCS:
writer.update_document(**doc)
writer.commit()
print(f"Indexed {len(DOCS)} documents.")
python build_index.py
4. A search helper
Keep the search logic in one place. We use a MultifieldParser so
a plain query like python search matches across both
title and body, ask Whoosh for HTML highlights of the
matched terms, and support simple pagination.
# search_index.py (continued)
from whoosh.qparser import MultifieldParser
def search(ix, query_str, page=1, per_page=10):
with ix.searcher() as searcher:
parser = MultifieldParser(["title", "body"], schema=ix.schema)
query = parser.parse(query_str)
results = searcher.search_page(query, page, pagelen=per_page)
results.results.fragmenter.surround = 60
hits = []
for hit in results:
hits.append({
"id": hit["id"],
"title": hit["title"],
"score": round(hit.score, 3),
"highlight": hit.highlights("body") or hit["body"][:160],
})
return {
"total": results.total,
"page": page,
"per_page": per_page,
"results": hits,
}
search_page gives you paginated results and a
.total count for free; hit.highlights("body") returns a
snippet with the matched terms wrapped in <b class="match">
tags, ready to render.
5. The FastAPI endpoint
Now wire it up. Two FastAPI-specific things matter here:
- Open the index once. Opening it per request is wasteful,
so we open it at startup with
lifespanand hand it to routes via a dependency. - Don't block the event loop. Whoosh does synchronous file
I/O. In an
async defroute, run it in a threadpool withrun_in_threadpool(which FastAPI/Starlette provides) so a slow query can't stall other requests.
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Query
from starlette.concurrency import run_in_threadpool
from pydantic import BaseModel
from search_index import get_index, search
state = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
state["ix"] = get_index() # open once at startup
yield
state["ix"].close() # clean up on shutdown
app = FastAPI(lifespan=lifespan)
def get_ix():
return state["ix"]
class Hit(BaseModel):
id: str
title: str
score: float
highlight: str
class SearchResponse(BaseModel):
total: int
page: int
per_page: int
results: list[Hit]
@app.get("/search", response_model=SearchResponse)
async def search_endpoint(
q: str = Query(..., min_length=1, description="Search query"),
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
ix=Depends(get_ix),
):
# Whoosh is synchronous — run it off the event loop.
return await run_in_threadpool(search, ix, q, page, per_page)
Run it:
uvicorn main:app --reload
and try a query:
curl "http://127.0.0.1:8000/search?q=python+search"
{
"total": 1,
"page": 1,
"per_page": 10,
"results": [
{
"id": "2",
"title": "Full-text search in Python",
"score": 6.214,
"highlight": "Whoosh is a pure-<b class=\"match term0\">Python</b> <b class=\"match term1\">search</b> library with BM25 ranking and highlighting"
}
]
}
Because we declared a Pydantic response_model, FastAPI documents
the whole thing automatically — open /docs and the
/search endpoint is fully typed in the interactive Swagger UI.
6. Keeping the index fresh
When your underlying data changes, update the index. The simplest robust pattern is a small write function you call from wherever your data mutates (a background task, a management command, a webhook):
def upsert(ix, doc):
writer = ix.writer()
writer.update_document(**doc) # insert or replace by unique id
writer.commit()
def delete(ix, doc_id):
writer = ix.writer()
writer.delete_by_term("id", doc_id)
writer.commit()
Only one writer can hold the lock at a time, so funnel writes through a single path — a background worker or a queue — rather than opening a writer inside every request handler. Reads (searchers) are cheap and can happen concurrently.
AND/OR/NOT, quoted
"exact phrases", field:value syntax, wildcards, and
ranges. Add fuzzy matching and "did you mean?" suggestions with the
spelling correction guide,
or type-ahead with prefix
autocomplete.Is Whoosh the right choice here?
For a single FastAPI service — a docs site, an internal tool, an API over a modest corpus — Whoosh is a great fit: no infrastructure, real BM25 ranking, did-you-mean spelling correction, and highlighting straight off the index. It runs the same everywhere Python does.
If you're indexing hundreds of millions of documents or need distributed sharding, that's a job for a dedicated engine — and the Whoosh vs SQLite FTS5 and Whoosh vs Tantivy comparisons are honest about the trade-offs. But for the very common middle ground — real ranked search inside your Python process — this is all you need.
Next steps
· Whoosh quickstart — the 5-minute API tour.
· Highlighting reference — customise snippets and fragmenters.
· Run the live in-browser demo — no install required.
Found this useful, or hit a snag? Whoosh is actively maintained again — issues and PRs are welcome, and I aim to respond promptly and kindly.