You have a Django project — a blog, a docs site, a catalogue — and you want a
real search box. Not icontains that misses word variants and can't
rank results, and not the operational weight of standing up Elasticsearch just to
search a few thousand rows.
Whoosh is a pure-Python full-text search library: BM25F ranking, stemming, phrase queries, and highlighting, with zero runtime dependencies and no server to run. You can drop it into any Django project — it doesn't care which database you use, and it works the same on SQLite in development and Postgres in production.
pip install whoosh3. whoosh3 is the
maintained continuation of the original Whoosh; the import name is still
whoosh, so existing code keeps working.
The plan
We'll add search to an app with an Article model. Four pieces:
- A small module that owns the Whoosh index (schema + open/create).
- A function to index one article (used for both bulk and incremental updates).
- A search view that parses the query, ranks results, and highlights matches.
- Signals so the index stays in sync when articles are saved or deleted.
1. Define the index
Put this in myapp/search.py. The schema mirrors the fields you want
to search. We store the primary key so we can map hits back to model rows, and give
the title a boost so title matches outrank body matches.
from pathlib import Path
from django.conf import settings
from whoosh import index
from whoosh.fields import Schema, TEXT, ID
from whoosh.analysis import StemmingAnalyzer
INDEX_DIR = Path(settings.BASE_DIR) / "whoosh_index"
SCHEMA = Schema(
pk=ID(unique=True, stored=True), # maps a hit back to Article.pk
title=TEXT(analyzer=StemmingAnalyzer(), stored=True, field_boost=2.0),
body=TEXT(analyzer=StemmingAnalyzer(), stored=True),
)
def get_index():
"""Open the index, creating it on first use."""
if not INDEX_DIR.exists():
INDEX_DIR.mkdir(parents=True, exist_ok=True)
if index.exists_in(str(INDEX_DIR)):
return index.open_dir(str(INDEX_DIR))
return index.create_in(str(INDEX_DIR), SCHEMA)
The StemmingAnalyzer is why this beats icontains:
searching deploy will also match deploying and deployed,
because words are reduced to their stems at both index and query time.
2. Index one article (upsert)
update_document is idempotent on the unique field: if a
document with that pk already exists, Whoosh replaces it; otherwise it
adds it. That single call handles both "create" and "update", so you never end up
with duplicates.
def index_article(article):
ix = get_index()
writer = ix.writer()
writer.update_document(
pk=str(article.pk),
title=article.title,
body=article.body,
)
writer.commit()
def unindex_article(pk):
ix = get_index()
writer = ix.writer()
writer.delete_by_term("pk", str(pk))
writer.commit()
For a one-off backfill of existing rows, add a management command
(myapp/management/commands/reindex.py) that opens a single writer and
loops — much faster than committing per row:
from django.core.management.base import BaseCommand
from myapp.models import Article
from myapp.search import get_index
class Command(BaseCommand):
help = "Rebuild the Whoosh search index from the database."
def handle(self, *args, **options):
ix = get_index()
writer = ix.writer()
count = 0
for article in Article.objects.all().iterator():
writer.update_document(
pk=str(article.pk),
title=article.title,
body=article.body,
)
count += 1
writer.commit()
self.stdout.write(self.style.SUCCESS(f"Indexed {count} articles."))
Run it once with python manage.py reindex.
3. The search view
MultifieldParser searches several fields at once. We fetch the ranked
hits, pull the matching model rows from the database in one query, and attach
highlighted snippets. Note the OrGroup: by default Whoosh requires
all query words to be present (AND); OrGroup makes a
multi-word query match documents containing any of the words, ranking those
with more matches higher — usually what a search box should do.
from django.shortcuts import render
from whoosh.qparser import MultifieldParser, OrGroup
from myapp.models import Article
from myapp.search import get_index
def search(request):
query_string = request.GET.get("q", "").strip()
results = []
if query_string:
ix = get_index()
with ix.searcher() as searcher:
parser = MultifieldParser(["title", "body"], schema=ix.schema,
group=OrGroup)
query = parser.parse(query_string)
hits = searcher.search(query, limit=20)
hits.fragmenter.surround = 50 # chars of context around a match
# one DB query for all matched rows, then keep Whoosh's ranking
pks = [int(hit["pk"]) for hit in hits]
by_pk = Article.objects.in_bulk(pks)
for hit in hits:
article = by_pk.get(int(hit["pk"]))
if article is None:
continue # row deleted but not yet unindexed
results.append({
"article": article,
"snippet": hit.highlights("body", text=article.body),
})
return render(request, "myapp/search.html", {
"query": query_string,
"results": results,
})
text=article.body to highlights() so snippets come
from the live database text. That means you don't have to keep the full body
stored=True in the index if you'd rather keep it small — drop
stored=True on body and highlighting still works.
The template
The snippet is safe HTML (Whoosh wraps matches in <b class="match">),
so mark it safe. Style .match in your CSS to make hits stand out.
<form method="get">
<input type="search" name="q" value="{{ query }}" placeholder="Search…">
<button type="submit">Search</button>
</form>
{% for result in results %}
<article>
<h3><a href="{{ result.article.get_absolute_url }}">{{ result.article.title }}</a></h3>
<p>{{ result.snippet|safe }}</p>
</article>
{% empty %}
{% if query %}<p>No results for "{{ query }}".</p>{% endif %}
{% endfor %}
4. Keep the index in sync
Wire model signals so every save or delete updates the index automatically. Put
this in myapp/signals.py and connect it from your app's
AppConfig.ready().
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from myapp.models import Article
from myapp.search import index_article, unindex_article
@receiver(post_save, sender=Article)
def on_article_saved(sender, instance, **kwargs):
index_article(instance)
@receiver(post_delete, sender=Article)
def on_article_deleted(sender, instance, **kwargs):
unindex_article(instance.pk)
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self):
from . import signals # noqa: F401 (registers the receivers)
Does it scale?
Whoosh comfortably handles tens to low-hundreds of thousands of documents on a single box, which covers most Django sites. If you're indexing millions of documents or need a distributed cluster, a dedicated engine is the right tool — and being honest about that is part of picking well. These comparisons lay out the trade-offs:
- Whoosh vs SQLite FTS5 — when the database's built-in search is enough.
- Whoosh vs Tantivy — pure Python vs a Rust engine, and when the jump is worth it.
Try it without installing anything. The live in-browser demo runs Whoosh in your browser via Pyodide — index some text and search it, no setup.
More to read:
· The same tutorial for Flask — the
framework-free version of the search logic.
· Whoosh quickstart — the 5-minute API tour.
· Highlighting reference — customise snippets and fragmenters.
· Integrations guide — Flask, Django, SQLite patterns.