Full-Text Search & Docker Deployment
Reviewed & published by Brayan K
PostgreSQL full-text search, exposed through django.contrib.postgres.search, lets you search and rank text by relevance far better than a simple substring match — and Docker lets you package that app, along with its database, into containers you can ship and run anywhere.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this final lesson you'll build relevance-ranked search with SearchVector, SearchQuery, and SearchRank, add fuzzy matching with TrigramSimilarity and a GIN index, then write a Dockerfile, run gunicorn, set production settings, and orchestrate Postgres with docker-compose.
What You'll Learn in This Lesson
- Full-text search with SearchVector and SearchQuery
- Ranking results by relevance with SearchRank
- Fuzzy matching with TrigramSimilarity and GIN indexes
- Writing a Dockerfile and running gunicorn
- Production settings: DEBUG=False, ALLOWED_HOSTS, env vars
- Orchestrating app and Postgres with docker-compose
1 Full-Text Search and Relevance Ranking
A query like filter(title__icontains=term) only matches exact substrings and cannot rank results. PostgreSQL full-text search, in django.contrib.postgres.search, normalizes text into lexemes so searching for "running" also finds "run", and lets you order by relevance.
The three core tools are SearchVector (turn columns into a searchable document), SearchQuery (turn the user's terms into a query), and SearchRank (score how well each row matches).
# blog/views.py
from django.contrib.postgres.search import (
SearchVector, SearchQuery, SearchRank,
)
from blog.models import Post
def search(request):
term = request.GET.get("q", "")
vector = SearchVector("title", "body") # the searchable document
query = SearchQuery(term) # the normalized query
results = (
Post.objects
.annotate(rank=SearchRank(vector, query))
.filter(rank__gt=0)
.order_by("-rank") # most relevant first
)
return results# Full-text search normalizes text into words (lexemes) and ranks
# rows by how many query terms they contain. We'll model that here.
posts = [
{"title": "Django ORM tips", "body": "Learn querysets and filters"},
{"title": "Deploy Django", "body": "Docker, gunicorn, and postgres"},
{"title": "Cooking pasta", "body": "A simple recipe"},
]
def to_lexemes(text):
return set(text.lower().split())
def search_rank(post, terms):
# SearchVector = all words across title + body
vector = to_lexemes(post["title"]) | to_lexemes(post["body"])
# SearchRank ~ how many query terms matched
return sum(1 for t in terms if t in vector)
query_terms = ["django", "docker"]
ranked = [(search_rank(p, query_terms), p["title"]) for p in posts]
ranked = [r for r in ranked if r[0] > 0]
ranked.sort(reverse=True) # most relevant first
for rank, title in ranked:
print(f"rank {rank}: {title}")
# ✅ Expected output:
# rank 1: Django ORM tips
# rank 1: Deploy Django2 Fuzzy Matching and GIN Indexes
Users misspell things. TrigramSimilarity compares three-character sequences, so a search for "djngo" still matches "django". It needs the Postgres pg_trgm extension. And to keep search fast on large tables, you add a GIN index on the search vector.
# Fuzzy search that tolerates typos
from django.contrib.postgres.search import TrigramSimilarity
def fuzzy_search(term):
return (
Post.objects
.annotate(similarity=TrigramSimilarity("title", term))
.filter(similarity__gt=0.3)
.order_by("-similarity")
)
# blog/models.py — add a GIN index for fast full-text search
from django.contrib.postgres.indexes import GinIndex
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
class Meta:
indexes = [GinIndex(fields=["title", "body"])]# TrigramSimilarity compares 3-character chunks (trigrams), so it can
# match misspellings. We'll compute a simple trigram similarity score.
def trigrams(word):
word = f" {word.lower()} "
return {word[i:i+3] for i in range(len(word) - 2)}
def similarity(a, b):
ta, tb = trigrams(a), trigrams(b)
if not ta or not tb:
return 0.0
# Jaccard-style overlap, like Postgres pg_trgm
return round(len(ta & tb) / len(ta | tb), 2)
print("django vs djngo: ", similarity("django", "djngo")) # typo, still high
print("django vs djano: ", similarity("django", "djano")) # another typo
print("django vs pasta: ", similarity("django", "pasta")) # unrelated, low
# ✅ Expected output:
# django vs djngo: 0.44
# django vs djano: 0.44
# django vs pasta: 0.03 Dockerizing and Deploying to Production
To ship the app, you package it in a Dockerfile, serve it with gunicorn, and turn on production settings: DEBUG = False, a real ALLOWED_HOSTS, secrets read from environment variables, and collectstatic to gather static files.
# Dockerfile
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
# Serve with a production WSGI server, not runserver
CMD ["gunicorn", "project.wsgi:application", "--bind", "0.0.0.0:8000"]# settings.py — production hardening
import os
DEBUG = os.environ.get("DEBUG", "False") == "True"
SECRET_KEY = os.environ["SECRET_KEY"] # from the environment
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ.get("DB_HOST", "db"),
"PORT": "5432",
}
}Finally, docker-compose runs the app container alongside a PostgreSQL container so your whole stack starts with one command.
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_DB: blog
POSTGRES_USER: blog
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
web:
build: .
command: gunicorn project.wsgi:application --bind 0.0.0.0:8000
ports:
- "8000:8000"
depends_on:
- db
environment:
DEBUG: "False"
SECRET_KEY: change-me
ALLOWED_HOSTS: example.com
POSTGRES_DB: blog
POSTGRES_USER: blog
POSTGRES_PASSWORD: secret
volumes:
pgdata:# Production settings are driven by environment variables. Here we
# simulate reading them and refusing to run unsafely (DEBUG in prod).
import os
# Pretend these came from docker-compose / the host environment
env = {
"DEBUG": "False",
"SECRET_KEY": "a-very-secret-key",
"ALLOWED_HOSTS": "example.com,www.example.com",
}
DEBUG = env.get("DEBUG", "False") == "True"
ALLOWED_HOSTS = env.get("ALLOWED_HOSTS", "").split(",")
print("DEBUG is:", DEBUG)
print("ALLOWED_HOSTS:", ALLOWED_HOSTS)
# A simple production safety check
if DEBUG:
print("REFUSING TO DEPLOY: DEBUG must be False in production!")
else:
print("Safe to deploy: serving with gunicorn on 0.0.0.0:8000")
# ✅ Expected output:
# DEBUG is: False
# ALLOWED_HOSTS: ['example.com', 'www.example.com']
# Safe to deploy: serving with gunicorn on 0.0.0.0:8000🎯 Your Turn: Make It Production-Safe
Fill in the blank with the value DEBUG must have in production so error pages don't leak sensitive details.
# 🎯 YOUR TURN: replace ___ with the value DEBUG must have in
# production (hint: it is the boolean False).
DEBUG = ___
if DEBUG:
print("UNSAFE: DEBUG should not be True in production")
else:
print("Production-ready: DEBUG is off")
# EXPECTED OUTPUT:
# Production-ready: DEBUG is offCommon Errors (And How to Fix Them)
❌ Static files (CSS/JS) are missing in production
With DEBUG = False, Django stops auto-serving static files, and you never collected them.
✅ Fix: run python manage.py collectstatic and serve STATIC_ROOT with your web server or WhiteNoise.
❌ DisallowedHost / Invalid HTTP_HOST header
Your domain is not listed in ALLOWED_HOSTS, which Django enforces once DEBUG is False.
✅ Fix: set ALLOWED_HOSTS (often from an env var) to include your real domain or container host.
❌ function search_query / similarity does not exist
You used full-text or trigram search without enabling the required Postgres extension.
✅ Fix: enable it (e.g. the pg_trgm extension) via a migration with TrigramExtension before using TrigramSimilarity.
📋 Quick Reference
| Tool | What It Does |
|---|---|
| SearchVector | Turn text columns into a searchable document |
| SearchRank | Score and order matches by relevance |
| TrigramSimilarity | Fuzzy matching that tolerates typos |
| collectstatic | Gather static files into STATIC_ROOT |
| gunicorn | Production WSGI server that runs your app |
🏆 Mini Challenge: Rank and Then Deploy-Check
Combine the two halves of this lesson: rank search results by relevance, then run a production safety check before "deploying".
# 🏆 Mini Challenge: rank search results, then verify deploy settings.
posts = [
{"title": "Django deployment with Docker", "body": "gunicorn and postgres"},
{"title": "Postgres full-text search", "body": "searchvector and rank"},
{"title": "Weekend baking", "body": "bread and cake"},
]
def rank(post, terms):
words = set((post["title"] + " " + post["body"]).lower().split())
return sum(1 for t in terms if t in words)
terms = ["django", "postgres"]
results = sorted(
[(rank(p, terms), p["title"]) for p in posts if rank(p, terms) > 0],
reverse=True,
)
print("Search results (most relevant first):")
for r, title in results:
print(f" rank {r}: {title}")
# Now the deploy check
DEBUG = False
print("Deploy check:", "OK" if not DEBUG else "BLOCKED - DEBUG is True")
# EXPECTED: two ranked results, then "Deploy check: OK"❓ Frequently Asked Questions
Lesson complete — and you've finished the Django course!
You can build relevance-ranked search with SearchVector, SearchQuery, and SearchRank, add fuzzy matching with TrigramSimilarity and a GinIndex, then package your app in a Dockerfile, serve it with gunicorn, harden it with DEBUG=False and ALLOWED_HOSTS, and run it alongside PostgreSQL using docker-compose.
🏆 Congratulations — you've completed the Django course! From your first view to full-text search and a production Docker deployment, you now have the full toolkit to design, build, test, and ship real Django applications. Keep building, and revisit any lesson whenever you need a refresher.
Practice quiz
Which module provides Django's PostgreSQL full-text search tools?
- django.contrib.postgres.search
- django.core.search
- django.contrib.search
- django.db.fulltext
Answer: django.contrib.postgres.search. SearchVector, SearchQuery, SearchRank, and TrigramSimilarity all live in django.contrib.postgres.search.
What does SearchVector do in a full-text query?
- It paginates results
- It escapes SQL injection
- It turns one or more text fields into a searchable document of normalized lexemes
- It connects to the database
Answer: It turns one or more text fields into a searchable document of normalized lexemes. SearchVector processes the chosen text columns into a tsvector of normalized lexemes you can search against.
What is SearchQuery used for?
- Creating database tables
- Parsing the user's search terms into a query matched against a SearchVector
- Caching query results
- Defining a model field
Answer: Parsing the user's search terms into a query matched against a SearchVector. SearchQuery turns the search terms into a normalized query that is matched against the SearchVector.
What does SearchRank let you do?
- Limit the number of results
- Index the database
- Encrypt the query
- Order matches by how well they match, so the most relevant rise to the top
Answer: Order matches by how well they match, so the most relevant rise to the top. SearchRank scores each match's relevance so you can order results with the best matches first.
Which class enables fuzzy matching that tolerates typos and partial spellings?
- TrigramSimilarity
- SearchVector
- SearchHeadline
- SearchConfig
Answer: TrigramSimilarity. TrigramSimilarity compares three-character sequences, so it matches misspellings and partial words.
Which index type is recommended to make full-text search fast on large tables?
- Hash index
- GIN index
- No index is needed
- B-tree index
Answer: GIN index. A GIN index on the search vector dramatically speeds up full-text queries on large tables.
In production, what must DEBUG be set to?
- 'production'
- It does not matter
- True
- False
Answer: False. DEBUG must be False in production so error pages do not leak sensitive details about your code and settings.
What does the collectstatic command do before deployment?
- Compiles Python to bytecode
- Runs database migrations
- Gathers all static files into STATIC_ROOT so a web server can serve them
- Starts the development server
Answer: Gathers all static files into STATIC_ROOT so a web server can serve them. collectstatic copies all app and project static files into STATIC_ROOT for serving in production.
What is gunicorn's role in a typical Django deployment?
- It is a caching backend
- It is a production WSGI server that runs your Django app instead of runserver
- It is a database engine
- It is a template engine
Answer: It is a production WSGI server that runs your Django app instead of runserver. gunicorn is a production-grade WSGI server that serves your app, replacing the development runserver.
What is the purpose of docker-compose in this setup?
- To define and run multiple containers together, such as the Django app and a PostgreSQL database
- To minify JavaScript
- To write database migrations
- To replace gunicorn
Answer: To define and run multiple containers together, such as the Django app and a PostgreSQL database. docker-compose defines and orchestrates multiple services together, for example the web app and a Postgres database.
Continue this course
- Previous: GraphQL APIs with Graphene
- Next: Static & Media Files on S3 — Serve static and user-uploaded media from S3 with django-storages and a CDN
- Quick reference: Django cheat sheet
Frequently asked questions
When should I use PostgreSQL full-text search instead of icontains?
A query like filter(title__icontains=term) is fine for tiny datasets, but it only matches exact substrings, ignores word stems, cannot rank results by relevance, and gets slow as the table grows because it scans every row. PostgreSQL full-text search, exposed through django.contrib.postgres.search, normalizes text into lexemes so that searching for 'running' also finds 'run', lets you rank matches with SearchRank, and becomes fast when you add a GIN index. Reach for it as soon as search quality or performance starts to matter.
What is the difference between SearchVector, SearchQuery, and SearchRank?
They are the three pieces of a full-text query. SearchVector turns the columns you want to search (such as title and body) into a normalized searchable document of lexemes. SearchQuery turns the user's typed terms into a normalized query of the same form. You annotate your queryset with the vector, filter where the vector matches the query, and then use SearchRank to score how well each row matches so you can order by relevance. TrigramSimilarity is a separate, fuzzier tool for tolerating typos.
Why do I need gunicorn and collectstatic to deploy?
The development server started by manage.py runserver is single-threaded and explicitly not meant for production. gunicorn is a robust WSGI server that runs many workers and serves your Django app under real traffic. Separately, runserver auto-serves static files only while DEBUG is True; in production you set DEBUG=False, so you run collectstatic to gather every static file into STATIC_ROOT, where a web server or a tool like WhiteNoise can serve them efficiently. Together they replace the conveniences of the dev server with production-grade equivalents.
What does docker-compose add on top of a Dockerfile?
A Dockerfile describes how to build one image, here the image that contains your Django app and gunicorn. Real apps need more than one service: at minimum a database. docker-compose is a YAML file that defines several containers, the web app and a PostgreSQL container for example, declares how they connect, mounts volumes so the database survives restarts, and starts them together with a single command. It turns 'build my app image' into 'run my whole stack', which is why it is the standard way to run a Dockerized Django project locally and in many deployments.