You have a Flask app — a blog, a docs site, an internal tool — and you want
a search box that actually ranks results instead of doing a slow
LIKE '%term%' scan. You don't want to run Elasticsearch, you don't
want a C extension in your Docker image, and you'd like the same code to run on
your laptop and your server.
That's exactly what Whoosh is for: a full-text search library written entirely in Python. No search server, no compiler, no native dependencies. In this guide we'll add ranked, highlighted search to a small Flask app in about 40 lines.
/search?q=... endpoint that
returns BM25-ranked results across a title and body field, with the matching
words highlighted — plus a clean way to keep the index in sync as your data
changes.
1. Install
pip install flask whoosh3
The maintained distribution is published as whoosh3 on PyPI, but
you still import whoosh — the package name and API are unchanged, so
existing tutorials and code keep working.
2. Define a schema
A Whoosh schema declares your fields and how each one is treated. For
a typical content app you want an ID to link back to your database
row, and one or more analysed TEXT fields to search. Boosting the
title makes title matches rank above body matches.
from whoosh.fields import Schema, TEXT, ID
schema = Schema(
id=ID(stored=True, unique=True),
title=TEXT(stored=True, field_boost=2.0),
body=TEXT(stored=True),
)
stored=True keeps a copy of the value in the index so you can show
it in results without a second database lookup. unique=True on the
id lets you update or delete a document by its id later.
3. Build the index once
An index lives in a directory on disk. Create it if it doesn't exist, then add
your documents. Using update_document (rather than
add_document) means re-running this is idempotent — a document with
the same id replaces the old copy instead of duplicating it.
import os
from whoosh.index import create_in, open_dir, exists_in
INDEX_DIR = "indexdir"
def get_index():
if exists_in(INDEX_DIR):
return open_dir(INDEX_DIR)
os.makedirs(INDEX_DIR, exist_ok=True)
return create_in(INDEX_DIR, schema)
def index_documents(docs):
ix = get_index()
writer = ix.writer()
for d in docs:
writer.update_document(id=str(d["id"]), title=d["title"], body=d["body"])
writer.commit()
Where do docs come from? Whatever your app already has — a list of
dicts, SQLAlchemy rows, Markdown files. Whoosh doesn't care; it only needs the
fields in your schema.
4. The search endpoint
To search across more than one field at once, use a
MultifieldParser. It turns a user's query string (python web,
"exact phrase", title:flask) into a query object, and the
searcher ranks matches with BM25 by default.
from flask import Flask, request, jsonify
from whoosh.qparser import MultifieldParser
app = Flask(__name__)
@app.route("/search")
def search():
query_string = request.args.get("q", "").strip()
if not query_string:
return jsonify(results=[])
ix = get_index()
results = []
with ix.searcher() as searcher:
parser = MultifieldParser(["title", "body"], schema=ix.schema)
query = parser.parse(query_string)
hits = searcher.search(query, limit=20)
hits.fragmenter.surround = 40 # chars of context around a match
for hit in hits:
results.append({
"id": hit["id"],
"title": hit["title"],
"score": round(hit.score, 3),
"snippet": hit.highlights("body"), # ...
})
return jsonify(results=results)
That's the whole feature. hit.highlights("body") returns an HTML
snippet with the matching terms wrapped in <b class="match">
tags, so you can drop it straight into a template and style it with CSS.
ix.refresh() to reuse an index object safely.
5. Keep the index in sync
When a row is created, updated, or deleted in your app, mirror the change into
the index. Because id is unique, updates and deletes are
one call each:
def upsert(doc):
ix = get_index()
w = ix.writer()
w.update_document(id=str(doc["id"]), title=doc["title"], body=doc["body"])
w.commit()
def remove(doc_id):
ix = get_index()
w = ix.writer()
w.delete_by_term("id", str(doc_id))
w.commit()
For bulk changes, batch them into a single writer() /
commit() rather than committing per document — commits are the
expensive part. If writes come from multiple workers, Whoosh serialises them with
a lock; wrap ix.writer() in a small retry, or funnel writes through a
background task, and reads stay fully concurrent.
6. Try it
index_documents([
{"id": 1, "title": "Getting started with Python",
"body": "Python is a versatile language for web and data work."},
{"id": 2, "title": "Full-text search basics",
"body": "Ranked search returns the most relevant documents first."},
{"id": 3, "title": "Deploying Flask apps",
"body": "Flask is a lightweight WSGI web framework written in Python."},
])
# then: curl "http://localhost:5000/search?q=python+web"
# -> doc 1 ranks first (title + body match), doc 3 follows.
Where this scales — and where it doesn't
This pattern comfortably handles the tens-of-thousands-to-low-millions of documents that most content sites, docs portals, and internal tools actually have, with no infrastructure beyond a directory on disk. You also get the parts of search people usually bolt on later for free: a real query language, phrase and field queries, faceting, and even did-you-mean spelling correction straight off the index.
If you're indexing hundreds of millions of documents or need distributed sharding, that's genuinely 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.