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.

Search inside PostgreSQLText fieldstitle, bodySearchVectorstemmed, weightedGIN indexfast matchingSearchRankordered resultsA stored, indexed search vector gives ranked, stemmed search without a separate service.
  1. Add django.contrib.postgres to INSTALLED_APPS.
  2. Add a SearchVectorField to the model and a GinIndex on it.
  3. Fill it with weighted vectors (SearchVector("title", weight="A") + SearchVector("body", weight="B")) using a database trigger or a generated column.
  4. Query with SearchQuery(q, search_type="websearch") and order by SearchRank.
  5. Use the right language configuration (config="english", or simple for mixed or names).
  6. 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.

from icontains to a stored vectorSpeed at 1M rowsQualityicontains✕slow, full scansubstring onlySearchVector on the fly!slow, no indexstemming, rankingStored vector + GIN✓fast✓stemming, weights, rankingTrigram (pg_trgm)fast with GINtypo tolerance on short textStore and index the vector; add trigrams for fuzzy title matchesdjango-deployment.com
Combine full-text for content with trigrams for names and titles.

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.

production-ready Postgres searchStored tsvector with GIN indexgenerated columnWeights for title vs bodyA and Bwebsearch query typenatural user inputSame config for vector and queryenglish, simple, ...Trigram fallback for typospg_trgm on titlesMost apps never need a separate search engine with these in placedjango-deployment.com
Build the GIN index concurrently on existing large tables.

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. SearchRank reads vectors of all matching rows; for very common terms, limit candidates first.
  • Highlighting with SearchHeadline is CPU-heavy; run it only on displayed results.
  • Accents. The unaccent extension with a custom text search configuration makes "cafe" match "café".

Next steps

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.

Keep going