PostgreSQL Full-Text Search for Django in Production
Run Django search on PostgreSQL full-text search in production: a stored SearchVectorField with a GIN index, weights and ranking, language configurations, trigram similarity for typos, keeping vectors updated, and when to move to a search engine.
Problem statement
Site search built with icontains gets slow as tables grow (it cannot use normal indexes) and returns poor results: no ranking, no stemming ("deploy" does not match "deployment"), and no tolerance for typos. Before adding Elasticsearch, consider PostgreSQL's built-in full-text search, which Django exposes through django.contrib.postgres.search. With a stored search vector and a GIN index, it handles millions of rows with relevance ranking, stemming, and weights, inside the database you already run.
- Add
django.contrib.postgrestoINSTALLED_APPS. - Add a
SearchVectorFieldto the model and aGinIndexon it. - Fill it with weighted vectors (
SearchVector("title", weight="A") + SearchVector("body", weight="B")) using a database trigger or a generated column. - Query with
SearchQuery(q, search_type="websearch")and order bySearchRank. - Use the right language configuration (
config="english", orsimplefor mixed or names). - Add trigram similarity (
pg_trgm) for typo-tolerant title matching.
Step-by-step solution
1. Model and index
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
class Article(models.Model):
title = models.CharField(max_length=300)
body = models.TextField()
search_vector = SearchVectorField(null=True, editable=False)
class Meta:
indexes = [GinIndex(fields=["search_vector"], name="article_search_gin")]
Computing SearchVector on the fly in each query works for small tables but cannot use the index; storing it is what makes search fast.
2. Keep the vector updated
The most reliable approach is a generated column (PostgreSQL 12+), which the database keeps in sync on every write:
migrations.RunSQL(
sql="""
ALTER TABLE blog_article DROP COLUMN search_vector;
ALTER TABLE blog_article ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
""",
reverse_sql="ALTER TABLE blog_article DROP COLUMN search_vector; ALTER TABLE blog_article ADD COLUMN search_vector tsvector;",
)
Django 5.0+ also supports GeneratedField for this in the model itself. Avoid updating vectors from post_save signals: bulk updates and raw SQL bypass them and the index drifts.
3. Query and rank
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchHeadline
from django.db.models import F
def search_articles(q):
query = SearchQuery(q, config="english", search_type="websearch")
return (
Article.objects.filter(search_vector=query)
.annotate(rank=SearchRank(F("search_vector"), query),
snippet=SearchHeadline("body", query, config="english", max_words=30))
.order_by("-rank")[:20]
)
websearch accepts natural input such as django "zero downtime" -heroku. SearchHeadline highlights matches but processes the full text of each result, so apply it only to the final page of results.
4. Typo tolerance with trigrams
from django.contrib.postgres.operations import TrigramExtension
from django.contrib.postgres.search import TrigramSimilarity
# migration: operations = [TrigramExtension()]
# index: GinIndex(fields=["title"], opclasses=["gin_trgm_ops"], name="article_title_trgm")
Article.objects.annotate(sim=TrigramSimilarity("title", q)).filter(sim__gt=0.3).order_by("-sim")
Use this as a fallback when full-text search returns nothing, or for autocomplete on names.
5. Languages
The configuration controls stemming and stop words. Use the content's language (english, german, french), store a per-row config for multilingual content, or use simple (no stemming) for identifiers and names. Query and vector must use the same configuration.
Verification check: EXPLAIN ANALYZE of a search query shows a Bitmap Index Scan on article_search_gin, results for "deploying" include articles mentioning "deployment", and search latency stays in the low milliseconds on production data.
Explanation
PostgreSQL full-text search converts text into a tsvector of normalised lexemes (stemmed words with positions and optional weights) and queries into a tsquery. A GIN index maps each lexeme to the rows containing it, so matching is an index lookup rather than a scan. ts_rank scores matches by frequency, proximity, and weights. Django wraps all of this in ORM expressions. Storing the vector avoids recomputing it per query and lets the index work, which is the difference between a demo and a production search.
Edge cases / notes
- When to move to a search engine: complex relevance tuning, faceting across many fields at scale, synonyms management, or search across multiple data sources.
- Ranking cost.
SearchRankreads vectors of all matching rows; for very common terms, limit candidates first. - Highlighting with
SearchHeadlineis CPU-heavy; run it only on displayed results. - Accents. The
unaccentextension with a custom text search configuration makes "cafe" match "café".
Next steps
- When requirements outgrow this, see Run Elasticsearch or OpenSearch for Django in Production.
- Add the GIN index safely with Add Database Indexes in Django Without Locking Tables.
FAQ
Is PostgreSQL full-text search good enough for Django?
For most site search, yes, with a stored vector, GIN index, weights, and ranking.
Why is SearchVector slow in my queries?
Computing it per query cannot use an index. Store it in a column with a GIN index.
How do I handle typos?
Use trigram similarity from pg_trgm for titles and names, alongside full-text search.
How do I keep the search vector updated?
Use a generated column or a trigger so every write updates it, rather than Django signals.