🔍 Add full-text search to a FastAPI app in pure Python

by Priya Sundaram · 19 July 2026 · a Whoosh tutorial

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.

Prefer Flask or Django? The same ideas apply — see the Flask and Django versions of this tutorial. This one focuses on the FastAPI-specific bits (dependency injection, async, and Pydantic response models).

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:

#  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.

Which query features do you get? The default parser understands 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.

⭐ Star Whoosh on GitHub  ·  Ask a question