Wagtail Django Elasticsearch

How to Set Up Wagtail with Elasticsearch

Wagtail's database search is fine for small sites, but it doesn't scale. Elasticsearch gives you full-text relevance scoring, field boosting, autocomplete, and faceted filtering at any content volume. This guide covers the complete setup — from installation to zero-downtime reindexing in production.

1. Prerequisites

Before starting, you need:

  • A working Wagtail project (Wagtail 4.x or 5.x)
  • Elasticsearch running — the quickest way is Docker:
# Elasticsearch 7
docker run -d --name es7 \
  -p 9200:9200 -p 9300:9300 \
  -e "discovery.type=single-node" \
  docker.elastic.co/elasticsearch/elasticsearch:7.17.0

# Elasticsearch 8 (security disabled for local dev)
docker run -d --name es8 \
  -p 9200:9200 \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  docker.elastic.co/elasticsearch/elasticsearch:8.12.0

Verify it is running:

curl http://localhost:9200
# {"name":"...","version":{"number":"7.17.0",...}}

2. Install the Python Client

The Python elasticsearch client version must match your cluster version. Wagtail uses this client under the hood via its search backend:

# For Elasticsearch 5.x
pip install "elasticsearch>=5.0,<6.0"

# For Elasticsearch 6.x
pip install "elasticsearch>=6.0,<7.0"

# For Elasticsearch 7.x  (most common today)
pip install "elasticsearch>=7.0,<8.0"

# For Elasticsearch 8.x
pip install "elasticsearch>=8.0,<9.0"

The corresponding Wagtail backend for each version:

# ES 5  →  wagtail.search.backends.elasticsearch5
# ES 6  →  wagtail.search.backends.elasticsearch6
# ES 7  →  wagtail.search.backends.elasticsearch7
# ES 8  →  wagtail.search.backends.elasticsearch7  (same backend, compatible)

Wagtail does not yet ship a dedicated elasticsearch8 backend — the ES7 backend works with ES8 clusters when xpack.security.enabled=false or when you configure HTTP basic auth via OPTIONS.


3. Configure the Search Backend

Replace Wagtail's default database backend with Elasticsearch in your settings. Put this in settings/base.py and override the URL per environment:

# settings/base.py
WAGTAILSEARCH_BACKENDS = {
    'default': {
        'BACKEND': 'wagtail.search.backends.elasticsearch7',
        'URLS':    ['http://localhost:9200'],
        'INDEX':   'mysite',
        'TIMEOUT': 5,
        'OPTIONS': {},
        'INDEX_SETTINGS': {
            'settings': {
                'number_of_shards':   1,
                'number_of_replicas': 0,   # set to 1+ in production
            }
        },
    }
}

For production, override the URL and replica count via environment variables rather than hardcoding:

# settings/production.py
import os

WAGTAILSEARCH_BACKENDS = {
    'default': {
        'BACKEND': 'wagtail.search.backends.elasticsearch7',
        'URLS':    [os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200')],
        'INDEX':   os.environ.get('ELASTICSEARCH_INDEX', 'mysite'),
        'TIMEOUT': 10,
        'INDEX_SETTINGS': {
            'settings': {
                'number_of_shards':   2,
                'number_of_replicas': 1,
            }
        },
    }
}

4. Add Search Fields to Your Models

Wagtail's search system uses declarative search_fields on your models. There are four field types:

  • SearchField — full-text searchable content
  • FilterField — exact-match filtering (dates, booleans, IDs)
  • AutocompleteField — prefix matching for search-as-you-type
  • RelatedFields — index fields from related models

Here is a complete blog page example:

from wagtail.models import Page
from wagtail.search import index
from wagtail.fields import RichTextField, StreamField


class BlogPage(Page):
    intro    = RichTextField(blank=True)
    body     = StreamField([...], use_json_field=True)
    category = models.ForeignKey('blog.Category', null=True, on_delete=models.SET_NULL)
    date     = models.DateField()

    search_fields = Page.search_fields + [
        index.SearchField('intro'),
        index.SearchField('body'),
        index.FilterField('date'),
        index.FilterField('category_id'),
        index.AutocompleteField('title'),
        index.RelatedFields('category', [
            index.SearchField('name'),
            index.FilterField('slug'),
        ]),
    ]

Page.search_fields already includes SearchField('title') and AutocompleteField('title'), so you only need to add your custom fields.


5. Build the Index

Once your models have search_fields, create the Elasticsearch index and populate it:

# Create the index mapping and index all content
python manage.py update_index

# Or rebuild from scratch (deletes and recreates the index)
python manage.py rebuild_index

update_index is incremental — it indexes objects modified since the last run. rebuild_index drops and recreates the entire index. Use update_index in scheduled tasks (cron or Celery beat) and rebuild_index after mapping changes.

Wagtail also automatically updates the index on page publish and unpublish via signals. For non-page models (snippets, images), you need to call index.insert_or_update_object manually in your save logic, or wire it up with a signal:

from wagtail.search import index as search_index
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Author

@receiver(post_save, sender=Author)
def update_author_index(sender, instance, **kwargs):
    search_index.insert_or_update_object(instance)

6. Write the Search View

Wagtail's search API wraps Elasticsearch transparently. You call .search() on any queryset and it returns scored results:

# search/views.py
from django.shortcuts import render
from wagtail.models import Page
from wagtail.search.models import Query


def search(request):
    search_query = request.GET.get('q', '').strip()
    search_results = []

    if search_query:
        search_results = (
            Page.objects.live()
            .search(search_query, order_by_relevance=True)
        )
        # Record the query hit so Wagtail can surface promoted results
        Query.get(search_query).add_hit()

    return render(request, 'search/search.html', {
        'search_query':   search_query,
        'search_results': search_results,
    })

Search within a specific page type by scoping the queryset:

from blog.models import BlogPage

search_results = (
    BlogPage.objects.live()
    .filter(locale=request.locale)
    .search(search_query)
)

A minimal search template:

{# search/search.html #}
<form action="{% url 'search' %}" method="get">
  <input type="text" name="q" value="{{ search_query }}">
  <button type="submit">Search</button>
</form>

{% if search_results %}
  {% for result in search_results %}
    <article>
      <h2><a href="{{ result.url }}">{{ result.title }}</a></h2>
      <p>{{ result.search_description }}</p>
    </article>
  {% endfor %}
{% elif search_query %}
  <p>No results for <strong>{{ search_query }}</strong>.</p>
{% endif %}

7. Autocomplete

AutocompleteField uses Elasticsearch's edge n-gram analyser under the hood, so prefix queries are fast even on large indexes. Add the field to your model (it's already on Page.title) and query with .autocomplete():

# In your model
search_fields = Page.search_fields + [
    index.AutocompleteField('title'),
    index.AutocompleteField('intro'),
]
# In a view or API endpoint
from wagtail.models import Page

def autocomplete(request):
    term = request.GET.get('q', '')
    results = (
        Page.objects.live()
        .autocomplete(term)
        .values('title', 'url_path')[:8]
    )
    return JsonResponse({'results': list(results)})

Keep autocomplete queries scoped and limited — returning 5–10 suggestions is plenty. Avoid running them on every keypress; debounce at 250–300ms in the frontend.


8. Boost Fields for Better Relevance

By default all SearchFields have equal weight. Use the boost parameter to tell Elasticsearch that a match in the title is more valuable than a match in the body:

search_fields = Page.search_fields + [
    index.SearchField('title',       boost=10),
    index.SearchField('intro',       boost=4),
    index.SearchField('body',        boost=1),
    index.SearchField('author_name', boost=2),
]

After changing boost values, run rebuild_index — boosts are baked into the index mapping and do not take effect until the index is recreated.

Good starting ratios: title 8–10×, intro/summary 3–5×, body 1×. Tune them based on user feedback and search analytics rather than guessing.


9. Filter Search Results

FilterField values are stored as exact-match terms in the index. You can apply queryset filters before calling .search() and Wagtail will push them down into the Elasticsearch query as filters (not as scoring queries, so they do not affect relevance):

from blog.models import BlogPage
import datetime

def search(request):
    query  = request.GET.get('q', '')
    cat    = request.GET.get('category')
    year   = request.GET.get('year')

    qs = BlogPage.objects.live()

    if cat:
        qs = qs.filter(category__slug=cat)

    if year:
        qs = qs.filter(date__year=year)

    results = qs.search(query, order_by_relevance=True)

    return render(request, 'search/search.html', {
        'search_query': query,
        'search_results': results,
    })

Only fields declared as FilterField can be used in queryset filters that get pushed to Elasticsearch. Filtering on a field that is not in search_fields will fall back to a database query, which defeats the purpose.


10. Indexing StreamField Content

This is the most common Wagtail + Elasticsearch headache. A plain index.SearchField('body') on a StreamField will index the raw JSON structure, not the human-readable text inside it.

The fix is to add a get_search_content method (Wagtail 4.x+):

from wagtail.blocks import RichTextBlock, CharBlock
from wagtail.search import index


class BlogPage(Page):
    body = StreamField([
        ('heading',  CharBlock()),
        ('richtext', RichTextBlock()),
    ], use_json_field=True)

    search_fields = Page.search_fields + [
        index.SearchField('body'),
    ]

    def get_body_text(self):
        """Return plain text from all StreamField blocks for indexing."""
        parts = []
        for block in self.body:
            if hasattr(block.value, 'source'):
                # RichTextBlock — strip HTML tags
                from wagtail.rich_text import get_text_for_indexing
                parts.append(get_text_for_indexing(block.value.source))
            elif isinstance(block.value, str):
                parts.append(block.value)
        return ' '.join(parts)

    # Point the SearchField at the method instead
    search_fields = Page.search_fields + [
        index.SearchField('get_body_text'),
    ]

The key insight: SearchField accepts a callable name, not just a model field name. Pointing it at a method that returns clean plain text gives Elasticsearch exactly what it needs.


11. Zero-Downtime Reindexing in Production

Running rebuild_index in production deletes the existing index and blocks search during reindexing. For a live site, use Elasticsearch aliases to switch atomically.

The pattern: build a new index with a timestamped name, then point the alias at it once it is ready. Wagtail supports this via the INDEX setting — set it to the alias name and manage the underlying index externally:

# management/commands/reindex_atomic.py
import time
from django.core.management.base import BaseCommand
from elasticsearch import Elasticsearch
from django.conf import settings
from django.core.management import call_command

class Command(BaseCommand):
    help = 'Reindex into a new index and swap the alias atomically.'

    def handle(self, *args, **options):
        es        = Elasticsearch(settings.ELASTICSEARCH_URL)
        alias     = settings.ELASTICSEARCH_INDEX          # e.g. 'mysite'
        new_index = f'{alias}_{int(time.time())}'

        # Override the index name for this run
        backends = settings.WAGTAILSEARCH_BACKENDS
        backends['default']['INDEX'] = new_index

        self.stdout.write(f'Indexing into {new_index}...')
        call_command('rebuild_index', '--noinput')

        # Atomically move the alias
        actions = [{'add': {'index': new_index, 'alias': alias}}]
        old = es.indices.get_alias(name=alias, ignore_unavailable=True)
        for idx in old:
            actions.insert(0, {'remove': {'index': idx, 'alias': alias}})

        es.indices.update_aliases({'actions': actions})
        self.stdout.write(self.style.SUCCESS(f'Alias {alias} now points to {new_index}'))

        # Delete old indexes
        for idx in old:
            es.indices.delete(index=idx)
            self.stdout.write(f'Deleted old index {idx}')

During the rebuild, search continues to hit the old index via the alias. The switch is atomic — there is no window where search returns zero results.


12. Common Gotchas

Mapping conflicts after adding fields

Elasticsearch does not allow changing an existing field's type in-place. If you add a new FilterField or change a SearchField to an AutocompleteField, you must run rebuild_index — not update_index. Forgetting this causes silent indexing failures.

update_index vs rebuild_index

update_index only processes objects modified since the last run (tracked via a timestamp). rebuild_index drops and recreates the mapping and indexes everything. Use update_index in cron; use rebuild_index only after schema changes.

The database search fallback

If Elasticsearch is unreachable, Wagtail silently falls back to database search rather than raising an error. Set up health-check monitoring on port 9200 so you know when the cluster is down — otherwise you may not notice degraded search for hours.

Large RichText fields and analyzers

By default, Wagtail uses Elasticsearch's standard analyser. For multilingual sites or content with lots of technical jargon, configure a custom analyser in INDEX_SETTINGS and set it per-field with the es_extra option:

index.SearchField('body', es_extra={
    'analyzer': 'english',
})

Pagination on search results

Wagtail search results support Django's Paginator directly. But Elasticsearch has a default max_result_window of 10,000. If you paginate beyond that, you will get an error. Either raise max_result_window in your index settings or switch to the search_after API for deep pagination.


Wrapping Up

The Wagtail + Elasticsearch integration is one of the cleanest in the Django ecosystem. Wagtail handles index lifecycle, result pagination, and Wagtail-specific concerns (live pages, locales, promoted results) transparently — you just define fields and call .search().

The parts that need extra care are StreamField indexing (always use a method, not the field directly), field mapping changes (always rebuild after schema changes), and production reindexing (use aliases to avoid downtime). Get those three right and you will have a rock-solid search layer that scales well beyond what any database can handle.