To build autocomplete (type-ahead) search in Django with django-haystack and Elasticsearch, the mechanism is an edge n-gram analyzer: you add an EdgeNgramField to your SearchIndex, rebuild the index, and query it with SearchQuerySet().autocomplete(field=term). Edge n-grams index every leading prefix of each word ("m", "mi", "mic", "micr"...), so a partial query matches as the user types.
This is the autocomplete deep-dive of a two-part pair. If you have not set up Haystack and Elasticsearch yet, start with our Haystack + Elasticsearch setup guide and come back here for type-ahead. One honest caveat up front for 2026: Haystack's Elasticsearch backend only goes up to ES 7.x, so version pinning matters — we cover that and the modern alternatives (elasticsearch-dsl, PostgreSQL trigram, Meilisearch/Typesense) below.
Key takeaways
- EdgeNgram is the engine of Haystack autocomplete. Add an
EdgeNgramField(commonly namedcontent_auto) to your index and query it withSearchQuerySet().autocomplete(content_auto=term). - Edge n-grams vs n-grams:
EdgeNgramFieldmatches prefixes ("front" side) and is right for type-ahead;NgramFieldmatches substrings anywhere and is heavier. - Single-letter queries need a config change. Haystack's edge n-gram filter defaults to
min_gram = 2; drop it to1by subclassing the backend — that is the clean replacement for the old query-builder hack. - Version reality (2026): django-haystack supports Elasticsearch 1.x/2.x/5.x and (since Haystack 3.0) 7.x. There is no official ES 8.x/9.x backend — pin
elasticsearch<8. - Since 2021, many teams run OpenSearch (AWS's fork after the SSPL relicense). Haystack's ES7 backend usually works against OpenSearch 1.x but is not officially tested.
- Often a search server is overkill. PostgreSQL
pg_trgmgives decent type-ahead with zero extra infra; Meilisearch and Typesense give best-in-class instant search with typo tolerance.
How does autocomplete work in Elasticsearch?
Autocomplete needs the index to match partial words, not whole tokens. The standard technique is the edge n-gram: at index time the analyzer breaks each term into its leading substrings.
For the title "micropyramid" an edge n-gram filter with min_gram = 2 and max_gram = 15 stores the tokens mi, mic, micr, micro, ... up to the full word. When the user types mic, that prefix is an exact token hit, so the result returns instantly.
Two terms to keep straight:
- n-gram (
NgramField) — windows taken from anywhere in the word (ic,cr,rop...). Good for "match in the middle", but the index is much larger and it is the wrong default for prefix type-ahead. - edge n-gram (
EdgeNgramField) — windows anchored to the front of the word only. Smaller index, and exactly what prefix autocomplete wants.
Haystack's Elasticsearch backend ships these analyzers for you: the edge filter defaults to min_gram = 2, the plain n-gram filter to min_gram = 3. That min_gram is why a single typed letter returns nothing until you change it.
How do I add an EdgeNgramField to a Haystack SearchIndex?
Keep your normal text document field for full search, and add a dedicated EdgeNgramField for type-ahead. Pointing it at the same attribute (here title) with model_attr is the simplest start; a prepare_content_auto method lets you combine several fields later. After adding the field, populate it with python manage.py rebuild_index (use update_index for ongoing incremental changes).
# books/search_indexes.py
from haystack import indexes
from books.models import Book
class BookIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True, use_template=True)
title = indexes.CharField(model_attr="title")
# This field powers autocomplete. EdgeNgram indexes leading prefixes
# of each word, so partial queries match as the user types.
content_auto = indexes.EdgeNgramField(model_attr="title")
def get_model(self):
return Book
def index_queryset(self, using=None):
return self.get_model().objects.all()
# Optional: build the autocomplete field from more than one attribute.
def prepare_content_auto(self, obj):
return " ".join([obj.title, obj.author or ""]).strip()How do I query autocomplete from Haystack?
Use SearchQuerySet().autocomplete() with the EdgeNgram field name as the keyword. Under the hood Haystack runs a prefix-style match against the edge n-gram tokens. Slice the queryset to cap how many suggestions you return.
from haystack.query import SearchQuerySet
# "mic" matches micropyramid, microservice, ... -> top 8 suggestions
results = SearchQuerySet().autocomplete(content_auto="mic")[:8]
titles = [r.title for r in results]
# Restrict to one model and combine with a filter if needed
results = (
SearchQuerySet()
.models(Book)
.autocomplete(content_auto="mic")[:8]
)Why doesn't a single letter return results — and how to fix it cleanly?
Out of the box, the edge filter's min_gram = 2 means one-character queries ("m") match nothing. The old workaround (and what the original version of this post did) was to subclass ElasticsearchSearchQuery and rewrite build_query_fragment to wrap the term in wildcards (*m*). That works but it bypasses the analyzer, turns every keystroke into a slow leading-wildcard scan, and breaks across Haystack versions.
The clean fix is to lower min_gram to 1 on the edge n-gram analyzer by subclassing the engine. The first snippet defines the custom backend/engine; the second points HAYSTACK_CONNECTIONS at it. Re-run rebuild_index afterwards so the new analyzer is applied — you keep fast token lookups and gain single-letter matching.
# books/search_backends.py
import copy
from haystack.backends.elasticsearch7_backend import (
Elasticsearch7SearchBackend,
Elasticsearch7SearchEngine,
)
class SingleCharElasticsearchBackend(Elasticsearch7SearchBackend):
def __init__(self, connection_alias, **connection_options):
# Copy so we don't mutate the shared class-level settings dict.
self.DEFAULT_SETTINGS = copy.deepcopy(self.DEFAULT_SETTINGS)
edge = self.DEFAULT_SETTINGS["settings"]["analysis"]["filter"][
"haystack_edgengram"
]
edge["min_gram"] = 1 # allow single-letter autocomplete
super().__init__(connection_alias, **connection_options)
class SingleCharElasticsearchEngine(Elasticsearch7SearchEngine):
backend = SingleCharElasticsearchBackend# settings.py
HAYSTACK_CONNECTIONS = {
"default": {
"ENGINE": "books.search_backends.SingleCharElasticsearchEngine",
"URL": "http://127.0.0.1:9200/",
"INDEX_NAME": "haystack_books",
},
}How do I wire autocomplete to a JSON endpoint and a search box?
Expose a small Django view that takes ?q= and returns JSON, then call it from the browser as the user types — no framework needed for a suggestion list. On the client, debounce the input so you query at most every ~200ms instead of on every keystroke, and escape any text you inject into the DOM. The first snippet is the view and URL; the second is the front-end fetch.
# books/views.py
from django.http import JsonResponse
from haystack.query import SearchQuerySet
def autocomplete(request):
term = request.GET.get("q", "").strip()
suggestions = []
if term:
sqs = SearchQuerySet().autocomplete(content_auto=term)[:8]
suggestions = [result.title for result in sqs]
return JsonResponse({"results": suggestions})
# books/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("autocomplete/", views.autocomplete, name="autocomplete"),
]// Plain JS: debounced fetch against /autocomplete/
const input = document.querySelector('#search');
const list = document.querySelector('#suggestions');
/** @type {number | undefined} */
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
const q = input.value.trim();
if (q.length < 1) {
list.innerHTML = '';
return;
}
// Debounce: wait for a pause in typing before hitting the server.
timer = window.setTimeout(async () => {
const res = await fetch(`/autocomplete/?q=${encodeURIComponent(q)}`);
const data = await res.json();
// textContent (not innerHTML) keeps user/result text from injecting markup.
list.replaceChildren(
...data.results.map((title) => {
const li = document.createElement('li');
li.textContent = title;
return li;
}),
);
}, 200);
});Which Elasticsearch versions does django-haystack actually support?
This is the part most older tutorials skip. Haystack's Elasticsearch support has always lagged the engine:
- Haystack 2.x — Elasticsearch 1.x and 2.x (with a separate 5.x backend added later).
- Haystack 3.0+ (2020 onward) — added an Elasticsearch 7.x backend (
elasticsearch7_backend) and dropped Python 2. - Elasticsearch 8.x / 9.x — no official Haystack backend as of 2026.
OpenSearch note: after Elasticsearch's 2021 relicense to the SSPL, AWS forked ES 7.10 into OpenSearch, which many teams now run (it is the engine behind Amazon OpenSearch Service). Haystack has no dedicated OpenSearch backend, but its ES7 backend usually works against OpenSearch 1.x because that line stays close to the ES 7.10 wire API. OpenSearch 2.x is less reliable through Haystack — test before you commit. If you are standing up a cluster, see our guide to running Elasticsearch/OpenSearch on AWS.
Whichever engine you target, pin both Haystack and the Python client to a 7.x line, or the index calls will fail against a newer cluster:
# requirements.txt — pin to a combination Haystack actually supports
django-haystack>=3.2,<4
elasticsearch>=7.0.0,<8Should you still use Haystack for autocomplete in 2026?
Haystack is still a reasonable choice if you already run it for full-text search and just need a type-ahead field. But because its engine support is frozen at ES 7.x, many teams now reach for an approach that tracks the engine more closely — or skips the heavy search server entirely. The honest options:
- django-elasticsearch-dsl — talks to Elasticsearch through the official
elasticsearch-dslclient, so you can use the native completion suggester or asearch_as_you_typefield and keep up with current ES versions. Closer to the engine, more code than Haystack. - PostgreSQL
pg_trgm+TrigramSimilarity— trigram matching inside the database you already have. No extra service to run or secure; great up to moderate scale and fuzzy enough to tolerate typos. - Meilisearch / Typesense — purpose-built instant-search engines with typo tolerance, prefix search, and sub-50ms responses out of the box. Hugely popular in 2026 for exactly this use case; both have Django-friendly clients.
Autocomplete approaches compared
| Approach | Type-ahead mechanism | Extra infra | Typo tolerance | Best for |
|---|---|---|---|---|
| Haystack + EdgeNgramField | Edge n-gram analyzer + .autocomplete() |
Elasticsearch/OpenSearch (ES 7.x only) | No (prefix only) | Teams already on Haystack who need a quick type-ahead field |
| django-elasticsearch-dsl | Native completion suggester / search_as_you_type |
Elasticsearch (current versions) | Partial (fuzzy suggester) | ES shops that want to track new engine versions |
Postgres pg_trgm |
TrigramSimilarity + GIN index |
None — your existing DB | Yes (similarity-based) | Small/medium apps avoiding a search server |
| Meilisearch / Typesense | Built-in prefix + typo engine | One lightweight service | Yes (excellent) | Best instant-search UX; new builds prioritising search |
The zero-infra option: PostgreSQL trigram autocomplete
If you do not already run Elasticsearch, trigram search in Postgres is often enough and adds nothing to operate. Enable the pg_trgm extension, add a GIN index for speed, then rank by TrigramSimilarity:
# 1) migration: enable the extension and add a trigram GIN index
from django.contrib.postgres.operations import TrigramExtension
from django.contrib.postgres.indexes import GinIndex
from django.db import migrations
class Migration(migrations.Migration):
operations = [
TrigramExtension(),
migrations.AddIndex(
"book",
GinIndex(
name="book_title_trgm",
fields=["title"],
opclasses=["gin_trgm_ops"],
),
),
]
# 2) query: rank by similarity, keep the close matches
from django.contrib.postgres.search import TrigramSimilarity
def suggest(term):
return (
Book.objects.annotate(sim=TrigramSimilarity("title", term))
.filter(sim__gt=0.2)
.order_by("-sim")[:8]
)Frequently Asked Questions
What is EdgeNgram and why does autocomplete need it?
An edge n-gram is a leading substring of a word: "micro" produces "mi", "mic", "micr", "micro". Autocomplete needs to match what a user has typed so far, which is a prefix, so at index time the analyzer stores all those prefixes as tokens. When the user types "mic", that prefix is an exact token hit and the result returns instantly. In Haystack you get this by adding an EdgeNgramField to your SearchIndex and querying it with SearchQuerySet().autocomplete(field=term).
What is the difference between EdgeNgramField and NgramField?
EdgeNgramField anchors n-grams to the front of each word, so it matches prefixes — the right choice for type-ahead. NgramField takes windows from anywhere in the word, so it can match in the middle of a term but produces a much larger index and noisier results. For an autocomplete box, use EdgeNgramField; reserve NgramField for "match a substring anywhere" cases.
Why doesn't Haystack autocomplete match single letters?
Haystack's edge n-gram filter defaults to min_gram = 2 (and the plain n-gram filter to 3), so one-character queries produce no tokens to match. The clean fix is to subclass the Elasticsearch engine, deep-copy DEFAULT_SETTINGS, set the haystack_edgengram filter's min_gram to 1, point HAYSTACK_CONNECTIONS at the custom engine, and rebuild the index. Avoid the old wildcard (*m*) query hack — it bypasses the analyzer and is slow.
Does django-haystack support Elasticsearch 8 or 9?
No. As of 2026 Haystack's Elasticsearch backends top out at 7.x (added in Haystack 3.0); earlier backends cover 1.x, 2.x, and 5.x. There is no official ES 8.x/9.x backend, so pin elasticsearch>=7,<8 with django-haystack>=3.2,<4. If you must be on ES 8/9, use django-elasticsearch-dsl with the native completion suggester instead.
Can I use OpenSearch instead of Elasticsearch with Haystack?
Usually, yes, for OpenSearch 1.x. After Elasticsearch's 2021 SSPL relicense, AWS forked ES 7.10 into OpenSearch, and the 1.x line stays close enough to the ES 7.10 wire API that Haystack's ES7 backend generally works against it (including Amazon OpenSearch Service). There is no dedicated OpenSearch backend, and OpenSearch 2.x is less reliable through Haystack, so test indexing and querying before you rely on it.
When should I use PostgreSQL trigram search instead of Elasticsearch?
Use pg_trgm when you do not already run a search server and your dataset is small to medium. It needs no extra infrastructure, enable the extension, add a GIN index with gin_trgm_ops, and rank with TrigramSimilarity, and it tolerates typos via similarity scoring. Move to Elasticsearch/OpenSearch, Meilisearch, or Typesense when you need large-scale relevance tuning, faceting, or sub-50ms instant search across millions of records.
Build it right the first time
Autocomplete looks simple until you hit min_gram, version pinning, OpenSearch quirks, and "should we even run a search server" decisions. We have shipped Django search and instant-search features across 50+ projects in our 12+ years (since 2014), choosing Haystack, elasticsearch-dsl, Postgres trigram, or Meilisearch/Typesense to fit each app. If you want type-ahead that is fast and maintainable, our Django development team can help you pick the right approach and ship it. For the foundations, start with our Haystack + Elasticsearch setup guide, and if you index documents, see indexing binary files in Haystack.