Full-Text Search in Django with Haystack & Elasticsearch

Blog / Django · April 17, 2018 · Updated June 10, 2026 · 9 min read
Full-Text Search in Django with Haystack & Elasticsearch

To add full-text search to a Django project with django-haystack and Elasticsearch, install django-haystack, point a HAYSTACK_CONNECTIONS entry at an Elasticsearch backend, declare a SearchIndex for each model in search_indexes.py, then run python manage.py rebuild_index to populate the index. From there you query with Haystack's SearchQuerySet or its built-in search view and templates. Haystack gives you a single API over multiple engines (Elasticsearch, Solr, Whoosh, Xapian), so you can swap backends without rewriting your search code.

One caveat to settle up front: django-haystack's Elasticsearch support is version-pinned and dated. It works cleanly with ES 1.x/2.x/5.x and (with care) 7.x, but it does not support ES 8.x/9.x. We cover exactly what to pin — and which modern alternatives to reach for — below.

Key takeaways

  • django-haystack is a backend-agnostic search layer for Django: write your indexing code once and run it on Elasticsearch, Solr, Whoosh, or Xapian.
  • The setup is four steps: install Haystack, configure HAYSTACK_CONNECTIONS, declare a SearchIndex, and run rebuild_index.
  • Pin your versions. Haystack's Elasticsearch backend targets ES 1.x/2.x/5.x/7.x; it is not compatible with ES 8.x/9.x. Match the Python elasticsearch client to your server's major version.
  • Since Elasticsearch's 2021 SSPL relicense, AWS forked the last open release as OpenSearch; Haystack's ES 7 backend talks to OpenSearch 1.x reasonably well, but test it.
  • Not every app needs Elasticsearch. PostgreSQL full-text search needs zero extra infrastructure; Meilisearch / Typesense give typo-tolerant search out of the box; django-elasticsearch-dsl is a thinner layer for modern ES.
  • For autocomplete and instant single-letter search, see the EdgeNgram deep-dive in our Haystack autocomplete guide.

When should you use Haystack vs other search options?

Before wiring up Elasticsearch, be honest about whether you need it. A site with a few thousand rows and a WHERE title ILIKE '%term%' query that's getting slow probably wants PostgreSQL full-text search, not a second service to run and patch. Reach for Haystack + Elasticsearch when you need relevance ranking, faceting, and the freedom to swap the underlying engine later. Here's how the common 2026 options compare:

Approach Extra infrastructure Typo tolerance Best for 2026 caveat
django-haystack + Elasticsearch Yes (ES/OpenSearch server) Limited (manual config) A pluggable backend abstraction with faceting and relevance Backend pinned to ES 1.x/2.x/5.x/7.x — no ES 8/9
django-elasticsearch-dsl Yes (ES server) Limited (manual config) Modern ES features, closer to the engine You write more ES-specific code; less portable
PostgreSQL full-text search None (uses your DB) Basic (trigram via pg_trgm) Small/medium apps already on Postgres Less powerful than a dedicated engine at large scale
Meilisearch / Typesense Yes (separate service) Excellent (built in) Instant, typo-tolerant "search-as-you-type" UX Newer ecosystem; Django integration is third-party

Rule of thumb: choose Haystack when you want one abstraction over pluggable backends; choose Postgres FTS when you want no extra service at all; choose Meilisearch/Typesense when typo-tolerant instant search is the headline feature.

Which Elasticsearch version works with Haystack?

This is the part most outdated tutorials skip. django-haystack ships separate backend classes per Elasticsearch major version, and it has not kept pace with recent releases:

  • ES 1.x / 2.xElasticsearch2SearchEngine (legacy)
  • ES 5.xElasticsearch5SearchEngine
  • ES 7.xElasticsearch7SearchEngine (the newest Haystack supports well)
  • ES 8.x / 9.xnot supported. Python-client and API changes broke compatibility.

So pin deliberately: target ES 7.x, install django-haystack 3.x, and pin the Python elasticsearch client to the matching major (>=7,<8). A mismatched client is the single most common cause of rebuild_index failures.

The OpenSearch wrinkle: in 2021 Elastic relicensed Elasticsearch under the SSPL (no longer OSI open-source), and AWS forked the last Apache-2.0 release into OpenSearch. OpenSearch 1.x is API-compatible enough that Haystack's ES 7 backend generally works against it, which is why many teams now run OpenSearch — especially on AWS. Test your exact versions; OpenSearch 2.x+ drifts further from the ES 7 API. If you're standing up a cluster, see our guide to running Elasticsearch/OpenSearch on AWS.

How do you install and configure django-haystack?

Install Haystack alongside a Python Elasticsearch client whose major version matches your server. Pinning is not optional here — it's the difference between a working index and silent failures.

# Match the Python client major version to your Elasticsearch server.
# Haystack's Elasticsearch backend supports ES 1.x, 2.x, 5.x and 7.x — NOT 8.x/9.x.

pip install django-haystack

# For an Elasticsearch 7.x server (also works with OpenSearch 1.x):
pip install "elasticsearch>=7,<8"

# For an Elasticsearch 5.x server, pin the matching client instead:
# pip install "elasticsearch>=5,<6"

Next, register Haystack and point it at your engine in settings.py. Three things matter: add haystack to INSTALLED_APPS, define a HAYSTACK_CONNECTIONS dict with the right ENGINE for your ES version, and choose a signal processor that keeps the index in sync.

# settings.py

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.sites",
    "haystack",          # add Haystack above your own apps
    "books",
]

# Point Haystack at your search engine. The ES 7 engine also speaks to OpenSearch 1.x.
HAYSTACK_CONNECTIONS = {
    "default": {
        "ENGINE": "haystack.backends.elasticsearch7_backend.Elasticsearch7SearchEngine",
        "URL": "http://127.0.0.1:9200/",
        "INDEX_NAME": "haystack_books",
    },
}

# Update the index automatically whenever a model is saved or deleted.
# Convenient in development; in production prefer a queued/batch processor so a
# single save doesn't block on a write to Elasticsearch (see the FAQ below).
HAYSTACK_SIGNAL_PROCESSOR = "haystack.signals.RealtimeSignalProcessor"

How do you define a SearchIndex?

A SearchIndex tells Haystack which model to index and which fields to store. You write one index class per model, conventionally in a search_indexes.py file inside the app. Subclass both indexes.SearchIndex and indexes.Indexable, then declare fields that mirror your model.

Every index needs exactly one document=True field (conventionally called text). That field is the main blob Haystack searches against, and it's usually populated from a small template rather than a single attribute. Use model_attr for direct mappings and prepare_<fieldname>() methods for computed values like a list of author names.

Here's a Book model and its index. (Indexing file contents — PDFs, Word docs — works too, via a content-extraction step; see our walkthrough on indexing binary files in Haystack.)

# books/models.py
from django.conf import settings
from django.db import models


class Book(models.Model):
    title = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(unique=True)
    description = models.TextField()
    authors = models.ManyToManyField(settings.AUTH_USER_MODEL)
    created_on = models.DateTimeField(auto_now_add=True)

    def __str__(self):          # Python 3: __str__, not __unicode__
        return self.title


# books/search_indexes.py
from haystack import indexes

from books.models import Book


class BookIndex(indexes.SearchIndex, indexes.Indexable):
    # The one required document=True field Haystack searches against.
    text = indexes.CharField(document=True, use_template=True)
    title = indexes.CharField(model_attr="title")
    description = indexes.CharField(model_attr="description")
    authors = indexes.MultiValueField()
    created_on = indexes.DateTimeField(model_attr="created_on")

    def get_model(self):
        return Book

    def prepare_authors(self, obj):
        # prepare_<fieldname> computes a value at index time.
        return [author.get_full_name() or author.username for author in obj.authors.all()]

    def index_queryset(self, using=None):
        # The objects Haystack will index when you run rebuild_index.
        return self.get_model().objects.all()

The text field uses use_template=True, so Haystack builds its searchable content from a template. By convention that lives at search/<app_label>/<model_name>_text.txt — here templates/search/books/book_text.txt. Put every field you want searchable into it; the syntax is ordinary Django template tags.

{# templates/search/books/book_text.txt #}
{{ object.title }}
{{ object.description }}
{% for author in object.authors.all %}{{ author.get_full_name }} {% endfor %}

How do you build the index and add a search view?

Defining an index doesn't populate it — you build it from your database. rebuild_index clears and recreates everything; update_index does an incremental pass that's ideal for cron. Run a full rebuild after you first add or change an index.

Haystack also ships a ready-made search view and URL conf. Including haystack.urls mounts a SearchView at search/ that reads the q query parameter, runs the search, paginates the results, and renders templates/search/search.html with query, page, and paginator in the context. The q field name is required.

# One-off: drop and rebuild the whole index from the database.
python manage.py rebuild_index

# Incremental: only (re)index objects changed in the last hour — good for cron.
python manage.py update_index --age 1
# urls.py — modern path()/include() syntax (Django 2.0+)
from django.urls import path, include

urlpatterns = [
    # ...your other routes...
    path("search/", include("haystack.urls")),
]
{# templates/search/search.html — rendered by Haystack's default SearchView #}
<form method="get" action="">
  <input type="text" name="q" value="{{ query }}">   {# the q field is required #}
  <button type="submit">Search</button>
</form>

{% if query %}
  <ul>
    {% for result in page.object_list %}
      <li>
        <a href="{{ result.object.get_absolute_url }}">{{ result.object.title }}</a>
        <p>{{ result.object.description|truncatewords:30 }}</p>
      </li>
    {% empty %}
      <li>No results found.</li>
    {% endfor %}
  </ul>

  {% if page.has_previous or page.has_next %}
    <div>
      {% if page.has_previous %}<a href="?q={{ query }}&amp;page={{ page.previous_page_number }}">&laquo; Prev</a>{% endif %}
      {% if page.has_next %}<a href="?q={{ query }}&amp;page={{ page.next_page_number }}">Next &raquo;</a>{% endif %}
    </div>
  {% endif %}
{% else %}
  <p>Enter a search term above.</p>
{% endif %}

How do you run queries with SearchQuerySet?

For anything beyond the default view, query the index directly with SearchQuerySet — a lazy, chainable API that mirrors Django's QuerySet. You can filter on the document=True content, narrow to specific models, order by relevance (_score), add faceting, and turn on highlighting.

  • Filtering: SearchQuerySet().filter(content="django") runs a full-text match.
  • Highlighting: .highlight() wraps matched terms in <em> so you can show context snippets.
  • Faceting: declare a field with faceted=True on the index, then .facet("authors") returns grouped counts via .facet_counts() — the basis for filter sidebars.

Want instant, as-you-type results that match on a single letter? That needs EdgeNgram analysis and a custom backend, covered step by step in our Haystack autocomplete and single-letter search guide.

# books/views.py
from django.shortcuts import render
from haystack.query import SearchQuerySet

from books.models import Book


def search_books(request):
    query = request.GET.get("q", "")

    # Restrict to one model and order by relevance score (highest first).
    results = (
        SearchQuerySet()
        .models(Book)
        .filter(content=query)
        .order_by("-_score")
    )

    # Highlighting: matched terms come back wrapped in <em>.
    highlighted = results.highlight()

    # Faceting: declare faceted=True on the index field first, then read counts.
    author_facets = (
        SearchQuerySet()
        .filter(content=query)
        .facet("authors")
        .facet_counts()
    )

    return render(request, "search/search.html", {
        "query": query,
        "page": highlighted,
        "author_facets": author_facets,
    })

Frequently Asked Questions

Is django-haystack still maintained in 2026?

Yes, but it moves slowly. django-haystack 3.x supports modern Django (3.2–5.x) and Python 3, and its Elasticsearch backend tops out at ES 7.x. If you need ES 8/9 features, use django-elasticsearch-dsl or a purpose-built engine such as Meilisearch instead. Haystack remains a solid choice when you value one API across pluggable backends.

Do I need Elasticsearch, or is PostgreSQL full-text search enough?

For many small-to-medium apps, Postgres FTS (SearchVector, SearchQuery, and SearchRank backed by a GIN index) is enough and adds zero infrastructure. Reach for Elasticsearch or OpenSearch when you need advanced relevance tuning, faceting at scale, typo tolerance, or millions of documents.

Why does rebuild_index fail or return no results?

The usual culprit is a version mismatch between the Python elasticsearch client and your server — pin the client to the server's major version. Also confirm the ENGINE in HAYSTACK_CONNECTIONS matches your ES version (e.g. Elasticsearch7SearchEngine for ES 7.x), the server is reachable at the URL, and your document=True template actually outputs text.

Can Haystack work with OpenSearch instead of Elasticsearch?

Often, yes. OpenSearch 1.x is close enough to the Elasticsearch 7 API that Haystack's ES 7 backend usually works against it. OpenSearch 2.x and later have drifted further, so test your specific versions. Many AWS teams run OpenSearch since the 2021 SSPL relicense.

How do I keep the search index up to date?

Use HAYSTACK_SIGNAL_PROCESSOR = "haystack.signals.RealtimeSignalProcessor" to update on every save and delete — convenient in development. In production that blocks writes, so prefer the default processor plus a scheduled update_index --age 1 (or a queued processor) to batch updates.

What's the difference between this guide and the autocomplete one?

This post is the foundation: install, configure, index, and query. The companion guide focuses on autocomplete — using EdgeNgram tokenizers and a custom backend so search matches partial words and even single letters. See Autocomplete with Haystack & Elasticsearch.


Search looks simple and quietly isn't: relevance tuning, index freshness, version pinning, and infrastructure all matter. If you'd rather ship it right the first time, MicroPyramid's Django development services team has built search for everything from content sites to data-heavy SaaS across 12+ years and 50+ projects. When you're ready to add as-you-type autocomplete, continue with our single-letter search deep-dive.

Share this article