Add full-text search to a Django app in pure Python

by Priya Sundaram · 15 July 2026 · a Whoosh tutorial

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.

Install it with 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:

  1. A small module that owns the Whoosh index (schema + open/create).
  2. A function to index one article (used for both bulk and incremental updates).
  3. A search view that parses the query, ranks results, and highlights matches.
  4. 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,
    })
We pass 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)
Concurrency & production note. A Whoosh index is a directory of files, and only one writer can commit at a time. For a single-process dev server or modest traffic this is fine. Under multiple workers, funnel writes through one place — commit in a background task (Celery/RQ), or batch commits — rather than opening a writer inside every web request. Reads (searchers) are cheap and can run concurrently.

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:

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.

⭐ Star Whoosh on GitHub  ·  Ask a question

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.