PostgreSQL in Depth Module 9 – Search, vectors and maps
Full-text and trigram search in PostgreSQL
Build a search page on PostgreSQL 18 with a weighted tsvector column, a GIN index, the right text search configuration, and typo tolerance from pg_trgm.
What you will learn
- Build a weighted tsvector column with a GIN index for a search page
- Parse user input safely with websearch_to_tsquery and rank the results
- Choose a text search configuration from pg_ts_config for the language you have
- Add typo tolerance and fast wildcard searches with pg_trgm
Before you start
What you’ll learn · Before you start
What you will learn
- Build a weighted tsvector column with a GIN index for a search page
- Parse user input safely with websearch_to_tsquery and rank the results
- Choose a text search configuration from pg_ts_config for the language you have
- Add typo tolerance and fast wildcard searches with pg_trgm
Before you start
On this page
A page that offers to search 20,000 articles cannot do it with LIKE '%monsoon running%': the server would read
every row, find only that exact phrase and return the matches in no useful order. PostgreSQL ships two better tools.
Full-text search turns text into normalised lexeme: A normalised form of a word that a text search configuration keeps, after lower-casing, stemming and stopword removal: under the english configuration 'runs' and 'running' both become 'run'., finds rows by lexeme through a
GIN: Generalized Inverted Index: an index that stores, for every element of a composite value (an array item, a jsonb key or value, a word of a tsvector), the rows that contain it. More in “GIN, GiST, SP-GiST, BRIN, hash and bloom” index and ranks them. Trigram search in PostgreSQL comes from the pg_trgm extension:
it compares strings by their three-character windows, which forgives typos and makes ILIKE '%term%' fast.
Four runs on PostgreSQL 18.6 build the page step by step: a sentence that becomes a tsvector: A PostgreSQL data type that holds a sorted set of lexemes, each with the word positions it came from, built from text by to_tsvector under a text search configuration., user input that becomes a tsquery: A PostgreSQL data type that holds a search expression over lexemes, combined with and (&), or (|), not (!) and word distance (<N>), matched against a tsvector by the @@ operator. without breaking on a stray quote, a weighted search column and a GIN index that put the best of 20,000 articles first, the text search configuration: A row of pg_ts_config that names a parser and a chain of dictionaries for text search: it decides which tokens the parser finds, which are dropped as stopwords and how the rest are stemmed to lexemes. that fits the language you have, and the cases where trigram: A three-letter window of a string, padded with two spaces at the start and one at the end. pg_trgm records every trigram of a string and compares strings by how many they share. are the better answer.
From text to lexemes and queries
Full-text search splits a sentence into lexemes, stores them in a tsvector, and matches them against a tsquery with
the @@ operator. to_tsvector('english', …) lower-cases the words, drops stopwords such as the, for and in,
and stems the rest to a canonical form, keeping each lexeme’s position in the sentence. The run sends one sentence of
12 words through it, then puts search-box text through the tsquery parsers:
lexemes_session.py
"""One sentence through to_tsvector, the tsquery parsers (to_tsquery's two syntax errors are the errors the session
expects), and @@ matching on PostgreSQL 18.6."""
from pg_session import Server
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("lexemes.sql", errors=2) Output
$ psql -X -a -f lexemes.sql
-- One short sentence through to_tsvector, three ways to turn user text into a tsquery, and @@ matching.
SELECT to_tsvector('english',
'Running shoes for the monsoon rains in Mumbai: waterproof and quick drying');
to_tsvector
-------------------------------------------------------------------------------------
'dri':12 'monsoon':5 'mumbai':8 'quick':11 'rain':6 'run':1 'shoe':2 'waterproof':9
(1 row)
-- The three parsers. plainto_tsquery joins the lexemes with &, phraseto_tsquery with <N> for word distance,
-- and websearch_to_tsquery handles "quoted phrases", OR between words and a leading - that drops a word; it also
-- forgives text that would be a syntax error for to_tsquery (an unclosed quote, a stray punctuation).
SELECT plainto_tsquery('english', 'running shoes') AS plain,
phraseto_tsquery('english', 'running shoes') AS phrase,
websearch_to_tsquery('english', '"monsoon rains" OR waterproof -mumbai') AS web;
plain | phrase | web
----------------+------------------+-------------------------------------------------
'run' & 'shoe' | 'run' <-> 'shoe' | 'monsoon' <-> 'rain' | 'waterproof' & !'mumbai'
(1 row)
-- to_tsquery refuses words with no operator between them, with or without an unclosed quote; websearch_to_tsquery
-- builds a query from the same text.
SELECT to_tsquery('english', 'monsoon rains or snow');
psql:lexemes.sql:12: ERROR: syntax error in tsquery: "monsoon rains or snow"
SELECT to_tsquery('english', '"monsoon rains or snow');
psql:lexemes.sql:13: ERROR: syntax error in tsquery: ""monsoon rains or snow"
SELECT websearch_to_tsquery('english', '"monsoon rains or snow') AS forgiving;
forgiving
---------------------------------
'monsoon' <-> 'rain' <2> 'snow'
(1 row)
-- Matching with @@: the phrase in order matches; the swapped phrase and the query without shoes do not.
WITH sentence AS (
SELECT to_tsvector('english',
'Running shoes for the monsoon rains in Mumbai: waterproof and quick drying') AS v
)
SELECT v @@ phraseto_tsquery('english', 'monsoon rains') AS exact_phrase,
v @@ phraseto_tsquery('english', 'rains monsoon') AS swapped,
v @@ websearch_to_tsquery('english', 'quick -shoes') AS has_quick_without_shoes
FROM sentence;
exact_phrase | swapped | has_quick_without_shoes
--------------+---------+-------------------------
t | f | f
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lexemes_session.py
lexemes.sql
-- One short sentence through to_tsvector, three ways to turn user text into a tsquery, and @@ matching.
SELECT to_tsvector('english',
'Running shoes for the monsoon rains in Mumbai: waterproof and quick drying');
-- The three parsers. plainto_tsquery joins the lexemes with &, phraseto_tsquery with <N> for word distance,
-- and websearch_to_tsquery handles "quoted phrases", OR between words and a leading - that drops a word; it also
-- forgives text that would be a syntax error for to_tsquery (an unclosed quote, a stray punctuation).
SELECT plainto_tsquery('english', 'running shoes') AS plain,
phraseto_tsquery('english', 'running shoes') AS phrase,
websearch_to_tsquery('english', '"monsoon rains" OR waterproof -mumbai') AS web;
-- to_tsquery refuses words with no operator between them, with or without an unclosed quote; websearch_to_tsquery
-- builds a query from the same text.
SELECT to_tsquery('english', 'monsoon rains or snow');
SELECT to_tsquery('english', '"monsoon rains or snow');
SELECT websearch_to_tsquery('english', '"monsoon rains or snow') AS forgiving;
-- Matching with @@: the phrase in order matches; the swapped phrase and the query without shoes do not.
WITH sentence AS (
SELECT to_tsvector('english',
'Running shoes for the monsoon rains in Mumbai: waterproof and quick drying') AS v
)
SELECT v @@ phraseto_tsquery('english', 'monsoon rains') AS exact_phrase,
v @@ phraseto_tsquery('english', 'rains monsoon') AS swapped,
v @@ websearch_to_tsquery('english', 'quick -shoes') AS has_quick_without_shoes
FROM sentence; pg_session.py
"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.
Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
Besides PostgreSQL's own programs, the sessions read only this folder (and an extension's own folder where a lesson
needs one), and they connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. A session may name more patterns (mask=) for numbers that change on
every run, such as the rates and times ANALYZE VERBOSE prints or the random key of pg_dump's \\restrict line; the
session's own file says which, and its record shows a fixed word in their place. The sessions never print the port.
A session stops with status 1 when a command fails that should have worked, or reports a different number of errors
than the session expects, so a run that went wrong is never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""
def __init__(self, settings=None, initdb=(), name="data"):
self.settings = dict(settings or {})
self.initdb_args = list(initdb)
self.name = name
self.running = []
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
self.port = free_port()
self.password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(self.password + "\n")
self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
"PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "60"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
self.env.pop(key, None)
for script in Path(".").glob("*.sql"):
shutil.copy(script, self.base / script.name)
for data in Path(".").glob("*.csv"):
shutil.copy(data, self.base / data.name)
return self
def __exit__(self, *exc):
for folder, _port in reversed(self.running):
self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
self.running = []
shutil.rmtree(self.base, ignore_errors=True)
# ---- the cluster ---------------------------------------------------------------------------------------------
def initdb(self, show=False):
args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
*self.initdb_args]
if not show:
args.append("--no-instructions")
self._expect(args, show=show)
with open(self.base / self.name / "postgresql.conf", "a") as conf:
conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
def start(self, show=False, folder=None, port=None):
folder = folder or self.name
self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
self.running.append((folder, port or self.port))
def up(self):
"""initdb and start, quietly."""
self.initdb()
self.start()
return self
def standby(self, folder="standby"):
"""A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
port = free_port()
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {port}\n")
self.start(folder=folder, port=port)
for _ in range(300):
if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
return port
time.sleep(0.1)
sys.exit("the standby did not start streaming")
# ---- running things ------------------------------------------------------------------------------------------
def _call(self, args, env=None):
args = [str(a) for a in args]
done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=self.base, env=env or self.env)
return done.returncode, self.clean(done.stdout)
def _expect(self, args, show=False, status=0, mask=()):
code, out = self._call(args)
out = _masked(out, mask)
if show:
_show(args, out)
if code != status:
if not show:
print(out)
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
return text.replace(f'"{OS_USER}"', '"learner"')
def wait_for(self, sql, value):
"""Waits (unprinted) until a query returns the value, for example after a reload."""
for _ in range(300):
if self.query(sql) == value:
return
time.sleep(0.1)
sys.exit(f"{sql} never returned {value}")
def show_file(self, path):
"""Prints a text file of the cluster, as `cat` would."""
print(f"$ cat {path}")
print((self.base / path).read_text().rstrip("\n"))
print()
def query(self, sql, port=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
env = {**self.env, "PGPORT": str(port or self.port)}
code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only to give a role the run's password)."""
self.query(sql)
def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None, mask=()):
"""Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
`errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
env = {**self.env, "PGPORT": str(port or self.port)}
if user:
env["PGUSER"] = user
if db:
env["PGDATABASE"] = db
args = ["psql", "-X", *flags, "-f", script]
code, out = self._call(args, env=env)
out = _masked(out, mask)
shown = ["psql", "-X", *flags, "-f", script]
if user:
shown[1:1] = ["-U", user]
if db:
shown[1:1] = ["-d", db]
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
expected_status = 0 if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
def sessions(self, count):
"""Opens `count` idle psql connections (each one a client backend) until the with block ends."""
return _Idle(self, count)
def shell(self, *args, status=0, mask=()):
"""Runs one of PostgreSQL's programs and prints the command and its output."""
return self._expect(list(args), show=True, status=status, mask=mask)
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
print(textwrap.fill(" ".join(names), width=78, initial_indent=" ", subsequent_indent=" ",
break_on_hyphens=False))
print()
return names
def _masked(text, mask):
"""Replaces what differs on every run (a rate, a time, a random key) with fixed text (see the docstring)."""
for pattern, replacement in mask:
text = re.sub(pattern, replacement, text, flags=re.M)
return text
def _arg(a):
"""A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class _Idle:
"""Idle psql connections, closed again when the with block ends."""
def __init__(self, server, count):
self.server, self.count, self.procs = server, count, []
def __enter__(self):
before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
for _ in range(self.count):
self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=self.server.env, cwd=self.server.base))
sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
for _ in range(300):
# the query's own connection is one more client backend
if int(self.server.query(sql)) == before + self.count:
return self
time.sleep(0.1)
sys.exit("the idle connections did not open")
def __exit__(self, *exc):
for p in self.procs:
p.stdin.close()
p.wait(timeout=30) The 12 words became eight lexemes: run, shoe, monsoon, rain, mumbai, waterproof, quick and dri
(from drying). The four stopwords for, the, in and and were dropped, which is why positions 3, 4, 7 and 10
are missing, and the positions that remain let the server tell monsoon rains from rains monsoon. Each parser
builds its own tsquery: plainto_tsquery joins the lexemes with &, phraseto_tsquery joins them with <->
(followed by), and websearch_to_tsquery reads the syntax of a web search box: quoted phrases, the word or
between words, and a leading - that excludes a word. to_tsquery expects explicit operators, and the run shows it
refusing monsoon rains or snow with a syntax error, with or without an open quote in front, while
websearch_to_tsquery turned the same text into 'monsoon' <-> 'rain' <2> 'snow'.
The @@ operator asks whether a tsvector matches a tsquery. In the run, phraseto_tsquery('monsoon rains')
matched the sentence, the swapped rains monsoon did not, and websearch_to_tsquery('quick -shoes') did not match
because the sentence also carries shoes. The figure follows one article and one search through the pipeline the
rest of the lesson builds, the write path on the left and the read path on the right:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A row's title and body. Each article has a title and a body, stored as plain text. Nothing is normalised yet, so a search for 'running' on this column alone would need LIKE and read every row.
- Normalise to lexemes. to_tsvector('english', …) lower-cases the words, drops stopwords such as 'for', 'the' and 'in', and stems the rest. The first run's sentence of 12 words became eight lexemes with their positions, running as 'run' at 1 and drying as 'dri' at 12.
- setweight tags each section. setweight marks the title's lexemes A and the body's B. With the default weights, ts_rank_cd counts an A lexeme as 1.0 and a B lexeme as 0.4, so a title match outweighs a body match.
- GIN maps lexemes to rows. One posting list per lexeme names every row that holds it, in row order. A query that asks for a lexeme reads that one list, not every row of the table.
- The query becomes a tsquery. websearch_to_tsquery turns 'monsoon running' into 'monsoon' & 'run'. It accepts quoted phrases, OR between words and a leading - to drop a word, and never raises a syntax error.
- Match, then rank the rows. The GIN index reads the posting list of each query lexeme and returns a bitmap of candidate rows, which the heap scan rechecks. ts_rank_cd puts row 7 first at 1.4952; 800 rows tie at 0.4310, and the tie-breaker on id lists 25 and 50 next.
Treat the search box as untrusted
Send the user’s text as a query parameter, never spliced into the SQL string, and give it to
websearch_to_tsquery or plainto_tsquery, which accept any text and build a valid tsquery. to_tsquery expects
operators between words, so a search box built on it fails on ordinary typing, as the run’s two errors show.
Checkpoint
Check: Lexemes, tsquery and @@ matching
A search page: one STORED column, one GIN index
A real page keeps the tsvector in the table and asks a GIN index to find rows by lexeme. A
GENERATED ALWAYS AS (...) STORED column computes the vector from the row’s own text on every insert and update,
and a GIN index on it answers a query by looking up each lexeme of the tsquery. setweight(..., 'A') labels the
title’s lexemes and setweight(..., 'B') the body’s, and the ranking functions weigh the labels with the defaults
{0.1, 0.2, 0.4, 1.0} for D, C, B and A: a title lexeme counts 1.0, a body lexeme 0.4. The run builds 20,000
articles from fixed word lists; one in 25 says running and monsoon in both its title and its body, and row 7 is
rewritten so that its title and its body each put the two words side by side:
search_session.py
"""20,000 generated articles with a STORED weighted tsvector and a GIN index, on PostgreSQL 18.6. The one error the
session expects is CREATE INDEX on a virtual generated column, which PostgreSQL 18 refuses."""
from pg_session import Server
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'",
"max_parallel_maintenance_workers": 0}
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("search.sql", errors=1) Output
$ psql -X -a -f search.sql
-- 20,000 articles, built from generate_series and fixed word lists.
CREATE TABLE articles (
id int PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A')
|| setweight(to_tsvector('english', body), 'B')
) STORED
);
CREATE TABLE
-- Without STORED, PostgreSQL 18 makes the column VIRTUAL (computed on read), which cannot be indexed.
CREATE TABLE drafts (t text, v tsvector GENERATED ALWAYS AS (to_tsvector('english', t)));
CREATE TABLE
CREATE INDEX ON drafts USING GIN (v);
psql:search.sql:13: ERROR: indexes on virtual generated columns are not supported
INSERT INTO articles(id, title, body)
SELECT g, format('%s shoes for the %s', kind, season),
format('A guide to picking %s shoes in Mumbai during the %s season. Reviews, prices and fit tips.', kind, season)
FROM generate_series(1, 20000) AS g,
LATERAL (SELECT (ARRAY['running','walking','trail','trekking','rain'])[1 + (g % 5)] AS kind,
(ARRAY['monsoon','winter','summer','marathon','city'])[1 + ((g/5) % 5)] AS season) AS w;
INSERT 0 20000
-- Row 7 puts the two words side by side in its title and in its body.
UPDATE articles SET title = 'Monsoon running shoes buyer guide',
body = 'The best monsoon running shoes for Mumbai, with grip, waterproofing and quick drying.'
WHERE id = 7;
UPDATE 1
CREATE INDEX articles_search_gin ON articles USING GIN (search);
CREATE INDEX
ANALYZE articles;
ANALYZE
-- The plan of one search.
EXPLAIN (COSTS OFF)
SELECT id FROM articles WHERE search @@ websearch_to_tsquery('english', 'monsoon running');
QUERY PLAN
------------------------------------------------------------------
Bitmap Heap Scan on articles
Recheck Cond: (search @@ '''monsoon'' & ''run'''::tsquery)
-> Bitmap Index Scan on articles_search_gin
Index Cond: (search @@ '''monsoon'' & ''run'''::tsquery)
(4 rows)
-- Positions and weights of the two lexemes in rows 7 and 25.
SELECT id, lexeme, positions, weights FROM articles, unnest(search)
WHERE id IN (7, 25) AND lexeme IN ('monsoon', 'run') ORDER BY id, lexeme;
id | lexeme | positions | weights
----+---------+-----------+---------
7 | monsoon | {1,8} | {A,B}
7 | run | {2,9} | {A,B}
25 | monsoon | {5,16} | {A,B}
25 | run | {1,10} | {A,B}
(4 rows)
-- Matches per score, under ts_rank_cd and under ts_rank, which ignores proximity.
SELECT round(ts_rank_cd(search, q)::numeric, 4) AS rank_cd, round(ts_rank(search, q)::numeric, 4) AS rank,
count(*) AS matches
FROM articles, websearch_to_tsquery('english', 'monsoon running') AS q
WHERE search @@ q
GROUP BY 1, 2 ORDER BY 1 DESC;
rank_cd | rank | matches
---------+--------+---------
1.4952 | 0.9985 | 1
0.4310 | 0.9875 | 800
(2 rows)
-- The top three, ties broken by id, with snippets.
SELECT id, round(ts_rank_cd(search, q)::numeric, 4) AS rank_cd,
ts_headline('english', title || ' - ' || body, q,
'StartSel=[,StopSel=],MaxFragments=1,MaxWords=10,MinWords=5') AS snippet
FROM articles, websearch_to_tsquery('english', 'monsoon running') AS q
WHERE search @@ q
ORDER BY ts_rank_cd(search, q) DESC, id
LIMIT 3;
id | rank_cd | snippet
----+---------+----------------------------------------------------------------------------
7 | 1.4952 | [Monsoon] [running] shoes buyer guide - The best [monsoon] [running] shoes
25 | 0.4310 | [running] shoes for the [monsoon] - A guide to picking [running]
50 | 0.4310 | [running] shoes for the [monsoon] - A guide to picking [running]
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 search_session.py
search.sql
-- 20,000 articles, built from generate_series and fixed word lists.
CREATE TABLE articles (
id int PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A')
|| setweight(to_tsvector('english', body), 'B')
) STORED
);
-- Without STORED, PostgreSQL 18 makes the column VIRTUAL (computed on read), which cannot be indexed.
CREATE TABLE drafts (t text, v tsvector GENERATED ALWAYS AS (to_tsvector('english', t)));
CREATE INDEX ON drafts USING GIN (v);
INSERT INTO articles(id, title, body)
SELECT g, format('%s shoes for the %s', kind, season),
format('A guide to picking %s shoes in Mumbai during the %s season. Reviews, prices and fit tips.', kind, season)
FROM generate_series(1, 20000) AS g,
LATERAL (SELECT (ARRAY['running','walking','trail','trekking','rain'])[1 + (g % 5)] AS kind,
(ARRAY['monsoon','winter','summer','marathon','city'])[1 + ((g/5) % 5)] AS season) AS w;
-- Row 7 puts the two words side by side in its title and in its body.
UPDATE articles SET title = 'Monsoon running shoes buyer guide',
body = 'The best monsoon running shoes for Mumbai, with grip, waterproofing and quick drying.'
WHERE id = 7;
CREATE INDEX articles_search_gin ON articles USING GIN (search);
ANALYZE articles;
-- The plan of one search.
EXPLAIN (COSTS OFF)
SELECT id FROM articles WHERE search @@ websearch_to_tsquery('english', 'monsoon running');
-- Positions and weights of the two lexemes in rows 7 and 25.
SELECT id, lexeme, positions, weights FROM articles, unnest(search)
WHERE id IN (7, 25) AND lexeme IN ('monsoon', 'run') ORDER BY id, lexeme;
-- Matches per score, under ts_rank_cd and under ts_rank, which ignores proximity.
SELECT round(ts_rank_cd(search, q)::numeric, 4) AS rank_cd, round(ts_rank(search, q)::numeric, 4) AS rank,
count(*) AS matches
FROM articles, websearch_to_tsquery('english', 'monsoon running') AS q
WHERE search @@ q
GROUP BY 1, 2 ORDER BY 1 DESC;
-- The top three, ties broken by id, with snippets.
SELECT id, round(ts_rank_cd(search, q)::numeric, 4) AS rank_cd,
ts_headline('english', title || ' - ' || body, q,
'StartSel=[,StopSel=],MaxFragments=1,MaxWords=10,MinWords=5') AS snippet
FROM articles, websearch_to_tsquery('english', 'monsoon running') AS q
WHERE search @@ q
ORDER BY ts_rank_cd(search, q) DESC, id
LIMIT 3; The one error in the output is deliberate: without STORED, PostgreSQL 18 makes a generated column virtual,
computed when it is read, and CREATE INDEX on the drafts table’s column failed with indexes on virtual generated
columns are not supported. EXPLAIN (COSTS OFF) then showed the plan of a search on the real table: a
Bitmap Index Scan on articles_search_gin feeding a Bitmap Heap Scan on articles that rechecks the condition. The
index returns a bitmap of candidate rows and the heap scan confirms each one; the option An EXPLAIN option that leaves out the planner's estimated costs, row counts and row widths, so only the shape of the plan is printed. hides
the estimates, so the plan reads the same on every run.
The scores show what ts_rank_cd rewards. It is a cover-density ranking: it scores the stretches of the vector that
hold every query lexeme, and a short stretch scores more. Row 7 has monsoon and run side by side twice, at
positions 1 and 2 in the title (weight A) and 8 and 9 in the body (weight B), and scored 1.4952. The other 800
matches carry the same two words with the same weights, but four and six positions apart, and all 800 tied at
0.4310. ts_rank, which counts the matching lexemes and their weights but not how close they sit, gave the two
groups 0.9985 and 0.9875, almost a tie.
With 800 rows on one score, ordering by the rank alone would let LIMIT 3 return any three of them; the manual asks
for an ORDER BY that makes the order unique, so the query adds id and returns rows 25 and 50 after row 7.
ts_headline built a snippet of each, with the matching words between [ and ]. It reads the original text, not
the tsvector, and the manual warns that it can be slow, so a page computes snippets only for the rows it shows:
three here, not 801.
Key idea
A search page needs one weighted tsvector, kept in step with its text by a STORED generated column, one GIN
index on it, ts_rank_cd plus a tie-breaker for the order, and ts_headline for the rows on screen.
GIN, GiST, SP-GiST, BRIN, hash and bloom explains how a GIN
index stores, for every element, the rows that contain it; this page uses the index rather than teaching it. On a
table that takes many writes, the manual’s GIN chapter explains fastupdate and gin_pending_list_limit: new
entries wait in a pending list until a VACUUM, an autoanalyze or a full list merges them into the main index.
Virtual generated columns cannot be indexed in PostgreSQL 18
PostgreSQL 18 added virtual generated columns and made them the default, so GENERATED ALWAYS AS (...) without
STORED gives a column computed on every read, and the run’s drafts table shows what that costs a search: no
index. Keep a search column STORED. A trigger that rebuilds the tsvector on insert and update is the older way,
which the manual now describes as replaced by stored generated columns.
Checkpoint
Check: A STORED tsvector column and GIN
The configuration decides what a lexeme is
A text search configuration tells to_tsvector which parser to use and which dictionaries to apply to each kind of
token, and the dictionaries bring their stopword lists with them. The server ships a fixed set, so before a page
claims to search Hindi or Tamil, ask the installed cluster what it has:
config_session.py
"""Text search configurations and unaccent on PostgreSQL 18.6: what the server ships, lexemes under simple and
hindi, a configuration the server lacks (the one error the session expects), the volatility of unaccent, and the
IMMUTABLE wrapper an expression index needs."""
from pg_session import Server
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("config.sql", errors=1) Output
$ psql -X -a -f config.sql
-- What text search configurations this server actually ships. Only the ones in this list can parse text.
SELECT cfgname FROM pg_ts_config ORDER BY 1;
cfgname
------------
arabic
armenian
basque
catalan
danish
dutch
english
estonian
finnish
french
german
greek
hindi
hungarian
indonesian
irish
italian
lithuanian
nepali
norwegian
portuguese
romanian
russian
serbian
simple
spanish
swedish
tamil
turkish
yiddish
(30 rows)
-- A short Hindi sentence through two configurations. simple lower-cases each word and keeps it whole, particles
-- included; hindi sends each Devanagari word through the Snowball Hindi stemmer.
SELECT to_tsvector('simple', 'मुंबई में अच्छे बरसाती जूते') AS simple_lexemes;
simple_lexemes
-----------------------------------------
'अच्छे':3 'जूते':5 'बरसाती':4 'मुंबई':1 'में':2
(1 row)
SELECT to_tsvector('hindi', 'मुंबई में अच्छे बरसाती जूते') AS hindi_lexemes;
hindi_lexemes
-------------------------------------
'अच्छ':3 'जूत':5 'बरस':4 'म':2 'मुंब':1
(1 row)
-- What the stemmer buys: a search for the singular 'जूता' (a shoe) against a row that says 'जूते' (shoes).
SELECT to_tsvector('simple', 'जूते') @@ plainto_tsquery('simple', 'जूता') AS simple_matches,
to_tsvector('hindi', 'जूते') @@ plainto_tsquery('hindi', 'जूता') AS hindi_matches;
simple_matches | hindi_matches
----------------+---------------
f | t
(1 row)
-- A language the list does not have is an error, not a fallback.
SELECT to_tsvector('marathi', 'मुंबईत पावसाळी बूट');
psql:config.sql:11: ERROR: text search configuration "marathi" does not exist
LINE 1: SELECT to_tsvector('marathi', 'मुंबईत पावसाळी बूट');
^
-- unaccent removes accents and other diacritics, as its rules file lists them.
CREATE EXTENSION unaccent;
CREATE EXTENSION
SELECT unaccent('München Café résumé') AS unaccented;
unaccented
---------------------
Munchen Cafe resume
(1 row)
-- Why unaccent cannot be used in an expression index as it is: pg_proc records the single-argument form as
-- STABLE (provolatile = 's'), not IMMUTABLE, because its dictionary can change. The fix is a small IMMUTABLE
-- wrapper that names the function and the dictionary with their schema; CREATE INDEX then accepts it.
SELECT proname, provolatile FROM pg_proc WHERE proname = 'unaccent' AND pronargs = 1;
proname | provolatile
----------+-------------
unaccent | s
(1 row)
CREATE FUNCTION f_unaccent(text) RETURNS text AS $$
SELECT public.unaccent('public.unaccent'::regdictionary, $1)
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
CREATE FUNCTION
CREATE TABLE cafes (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE
INSERT INTO cafes VALUES (1, 'Café'), (2, 'München'), (3, 'Résumé');
INSERT 0 3
CREATE INDEX cafes_name_lower_unaccent ON cafes (lower(f_unaccent(name)));
CREATE INDEX
-- A search for 'cafe' now reaches the row stored as 'Café'.
SELECT id, name FROM cafes WHERE lower(f_unaccent(name)) = lower(f_unaccent('cafe'));
id | name
----+------
1 | Café
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 config_session.py
config.sql
-- What text search configurations this server actually ships. Only the ones in this list can parse text.
SELECT cfgname FROM pg_ts_config ORDER BY 1;
-- A short Hindi sentence through two configurations. simple lower-cases each word and keeps it whole, particles
-- included; hindi sends each Devanagari word through the Snowball Hindi stemmer.
SELECT to_tsvector('simple', 'मुंबई में अच्छे बरसाती जूते') AS simple_lexemes;
SELECT to_tsvector('hindi', 'मुंबई में अच्छे बरसाती जूते') AS hindi_lexemes;
-- What the stemmer buys: a search for the singular 'जूता' (a shoe) against a row that says 'जूते' (shoes).
SELECT to_tsvector('simple', 'जूते') @@ plainto_tsquery('simple', 'जूता') AS simple_matches,
to_tsvector('hindi', 'जूते') @@ plainto_tsquery('hindi', 'जूता') AS hindi_matches;
-- A language the list does not have is an error, not a fallback.
SELECT to_tsvector('marathi', 'मुंबईत पावसाळी बूट');
-- unaccent removes accents and other diacritics, as its rules file lists them.
CREATE EXTENSION unaccent;
SELECT unaccent('München Café résumé') AS unaccented;
-- Why unaccent cannot be used in an expression index as it is: pg_proc records the single-argument form as
-- STABLE (provolatile = 's'), not IMMUTABLE, because its dictionary can change. The fix is a small IMMUTABLE
-- wrapper that names the function and the dictionary with their schema; CREATE INDEX then accepts it.
SELECT proname, provolatile FROM pg_proc WHERE proname = 'unaccent' AND pronargs = 1;
CREATE FUNCTION f_unaccent(text) RETURNS text AS $$
SELECT public.unaccent('public.unaccent'::regdictionary, $1)
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
CREATE TABLE cafes (id int PRIMARY KEY, name text NOT NULL);
INSERT INTO cafes VALUES (1, 'Café'), (2, 'München'), (3, 'Résumé');
CREATE INDEX cafes_name_lower_unaccent ON cafes (lower(f_unaccent(name)));
-- A search for 'cafe' now reaches the row stored as 'Café'.
SELECT id, name FROM cafes WHERE lower(f_unaccent(name)) = lower(f_unaccent('cafe')); The pinned cluster listed 30 configurations, from arabic to yiddish, among them english, hindi, nepali,
tamil and simple, which only lower-cases each word and keeps it. The Hindi sentence through simple kept all five
words whole, the particle में included; hindi ran each word through its stemmer, shortening मुंबई to मुंब
and जूते to जूत, and reduced में to म rather than dropping it. The next query shows what the stemmer buys: a
search for the singular जूता matched the plural जूते under hindi and not under simple. A stemmer finds more
forms of a word but can also merge words a reader would keep apart, so run both over your real documents and
searches before you choose. Naming a configuration the server lacks gives an error, not a fallback:
to_tsvector('marathi', …) stopped with text search configuration “marathi” does not exist.
unaccent is a different tool: a contrib dictionary that removes the accents and other diacritics its rules file
lists, so München Café résumé became Munchen Cafe resume. The run read provolatile from pg_proc and found s
for the one-argument form, STABLE: its result depends on a dictionary that can be changed, not only on its argument,
and CREATE INDEX accepts only IMMUTABLE functions. The fix is a small SQL wrapper declared IMMUTABLE that names
the function and the dictionary with their schema, public.unaccent('public.unaccent'::regdictionary, $1), so
search_path cannot change what it calls. The index on lower(f_unaccent(name)) was then accepted, and a search for
cafe found Café. The label is a promise PostgreSQL does not check: after an edit of the rules file, rebuild the
index with REINDEX.
Which Indian languages have a configuration
pg_ts_config on this server lists hindi, nepali and tamil; Marathi, Bengali, Gujarati, Kannada, Malayalam,
Punjabi and Telugu have no configuration of their own. Two runs in this lesson matter for text in Indian scripts:
the hindi stemmer above, and the trigram run below, where pg_trgm on this server ignores Devanagari vowel
signs. Measure both on your own text before you rely on either.
Checkpoint
Check: Text search configurations and unaccent
Trigrams for typos and ILIKE wildcards
Trigram search in PostgreSQL answers what full-text search cannot: a misspelt word and a fragment in the middle of a
string. The pg_trgm extension takes every word of a string, puts two spaces in front of it and one behind, and
records each window of three characters. show_trgm('postgresql') returned eleven: " p", " po", pos,
ost, stg, tgr, gre, res, esq, sql and "ql ". Two strings are more alike the more windows they share, and similarity
measures it:
trigrams_session.py
"""pg_trgm on PostgreSQL 18.6: show_trgm, similarity, the % operator at the default threshold, which characters make
trigrams, and a GIN trigram index that turns ILIKE '%...%' from a Seq Scan into a Bitmap Index Scan once the pattern
holds a trigram."""
from pg_session import Server
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'",
"max_parallel_maintenance_workers": 0}
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("trigrams.sql") Output
$ psql -X -a -f trigrams.sql
-- Trigrams: the extension records every three-character window of each word (with two spaces in front of the word
-- and one behind it), and compares strings by how many trigrams they share.
CREATE EXTENSION pg_trgm;
CREATE EXTENSION
SELECT show_trgm('postgresql');
show_trgm
-----------------------------------------------------
{" p"," po",esq,gre,ost,pos,"ql ",res,sql,stg,tgr}
(1 row)
-- A typo and the correct spelling share many trigrams.
SELECT similarity('postgresql', 'postgersql') AS score;
score
------------
0.46666667
(1 row)
-- The default threshold the % operator uses.
SHOW pg_trgm.similarity_threshold;
pg_trgm.similarity_threshold
------------------------------
0.3
(1 row)
SELECT 'postgresql' % 'postgersql' AS over_threshold;
over_threshold
----------------
t
(1 row)
-- Only characters this server counts as letters or digits make trigrams: here the Devanagari vowel signs do not,
-- so 'जूते' (shoes) and 'जूता' (a shoe) compare as the same string.
SELECT similarity('जूते', 'जूता') AS shoes_vs_shoe;
shoes_vs_shoe
---------------
1
(1 row)
-- A table of 20,000 names, built deterministically with setseed before any random().
SELECT setseed(0.42);
setseed
---------
(1 row)
CREATE TABLE names (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE
INSERT INTO names(id, name)
SELECT g,
(ARRAY['Ananya','Arjun','Bhavna','Chetan','Divya','Esha','Farhan','Gaurav','Harini','Ishan',
'Jaya','Kiran','Leela','Manish','Nisha','Omkar','Priya','Rahul','Sneha','Tanvi'])[1 + floor(random()*20)::int]
|| ' '
|| (ARRAY['Agarwal','Banerjee','Chatterjee','Deshpande','Iyer','Joshi','Kapoor','Mehta','Nair','Patel',
'Rao','Shah','Singh','Verma','Yadav','Pillai','Reddy','Khan','Das','Bose'])[1 + floor(random()*20)::int]
FROM generate_series(1, 20000) AS g;
INSERT 0 20000
ANALYZE names;
ANALYZE
-- Before an index: an ILIKE with leading and trailing wildcards reads every row.
EXPLAIN (COSTS OFF)
SELECT id, name FROM names WHERE name ILIKE '%meht%';
QUERY PLAN
-------------------------------------
Seq Scan on names
Filter: (name ~~* '%meht%'::text)
(2 rows)
-- With a GIN trigram index the same ILIKE becomes a Bitmap Index Scan.
CREATE INDEX names_trgm ON names USING GIN (name gin_trgm_ops);
CREATE INDEX
EXPLAIN (COSTS OFF)
SELECT id, name FROM names WHERE name ILIKE '%meht%';
QUERY PLAN
-----------------------------------------------
Bitmap Heap Scan on names
Recheck Cond: (name ~~* '%meht%'::text)
-> Bitmap Index Scan on names_trgm
Index Cond: (name ~~* '%meht%'::text)
(4 rows)
-- Two letters between the wildcards hold no trigram to look up, so the index cannot narrow the search.
EXPLAIN (COSTS OFF)
SELECT id, name FROM names WHERE name ILIKE '%me%';
QUERY PLAN
-----------------------------------
Seq Scan on names
Filter: (name ~~* '%me%'::text)
(2 rows)
-- A misspelt name with the % operator, which the same trigram index can answer.
SELECT DISTINCT name, round(similarity(name, 'Nisha Mehtaa')::numeric, 4) AS sim
FROM names WHERE name % 'Nisha Mehtaa' ORDER BY sim DESC, name LIMIT 3;
name | sim
-------------+--------
Nisha Mehta | 0.7857
Esha Mehta | 0.4118
Ishan Mehta | 0.3889
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trigrams_session.py
trigrams.sql
-- Trigrams: the extension records every three-character window of each word (with two spaces in front of the word
-- and one behind it), and compares strings by how many trigrams they share.
CREATE EXTENSION pg_trgm;
SELECT show_trgm('postgresql');
-- A typo and the correct spelling share many trigrams.
SELECT similarity('postgresql', 'postgersql') AS score;
-- The default threshold the % operator uses.
SHOW pg_trgm.similarity_threshold;
SELECT 'postgresql' % 'postgersql' AS over_threshold;
-- Only characters this server counts as letters or digits make trigrams: here the Devanagari vowel signs do not,
-- so 'जूते' (shoes) and 'जूता' (a shoe) compare as the same string.
SELECT similarity('जूते', 'जूता') AS shoes_vs_shoe;
-- A table of 20,000 names, built deterministically with setseed before any random().
SELECT setseed(0.42);
CREATE TABLE names (id int PRIMARY KEY, name text NOT NULL);
INSERT INTO names(id, name)
SELECT g,
(ARRAY['Ananya','Arjun','Bhavna','Chetan','Divya','Esha','Farhan','Gaurav','Harini','Ishan',
'Jaya','Kiran','Leela','Manish','Nisha','Omkar','Priya','Rahul','Sneha','Tanvi'])[1 + floor(random()*20)::int]
|| ' '
|| (ARRAY['Agarwal','Banerjee','Chatterjee','Deshpande','Iyer','Joshi','Kapoor','Mehta','Nair','Patel',
'Rao','Shah','Singh','Verma','Yadav','Pillai','Reddy','Khan','Das','Bose'])[1 + floor(random()*20)::int]
FROM generate_series(1, 20000) AS g;
ANALYZE names;
-- Before an index: an ILIKE with leading and trailing wildcards reads every row.
EXPLAIN (COSTS OFF)
SELECT id, name FROM names WHERE name ILIKE '%meht%';
-- With a GIN trigram index the same ILIKE becomes a Bitmap Index Scan.
CREATE INDEX names_trgm ON names USING GIN (name gin_trgm_ops);
EXPLAIN (COSTS OFF)
SELECT id, name FROM names WHERE name ILIKE '%meht%';
-- Two letters between the wildcards hold no trigram to look up, so the index cannot narrow the search.
EXPLAIN (COSTS OFF)
SELECT id, name FROM names WHERE name ILIKE '%me%';
-- A misspelt name with the % operator, which the same trigram index can answer.
SELECT DISTINCT name, round(similarity(name, 'Nisha Mehtaa')::numeric, 4) AS sim
FROM names WHERE name % 'Nisha Mehtaa' ORDER BY sim DESC, name LIMIT 3; similarity('postgresql', 'postgersql') was 0.46666667: the typo keeps 7 of the 11 trigrams and adds 4 of its own,
and 7 shared out of 15 distinct trigrams is 0.4667. That is above the default pg_trgm.similarity_threshold of 0.3,
so the % operator returned true. Only characters the server counts as letters or digits make trigrams, and on this
cluster the Devanagari vowel signs are not among them: similarity('जूते', 'जूता'), shoes against a shoe, printed 1.
Trigram typo tolerance on Indian scripts therefore needs testing on your own text.
The table of 20,000 names, built with setseed(0.42) so that every run makes the same names, shows the plan change a
search box needs. Before any index, name ILIKE '%meht%' planned a Seq Scan: a B-tree cannot help a pattern that
starts with a wildcard. After CREATE INDEX names_trgm ON names USING GIN (name gin_trgm_ops), the same query
planned a Bitmap Index Scan on names_trgm: the operator class looks up the pattern’s trigrams, meh and eht,
and the heap scan rechecks the pattern on the rows that hold both. Two letters are too few: '%me%' holds no
trigram, so with the same index the plan stayed a Seq Scan; between two wildcards a pattern needs three characters
before the index can help. The same index answers %, and a search for the misspelt Nisha Mehtaa returned
Nisha Mehta at 0.7857, then Esha Mehta and Ishan Mehta.
GIN or GiST for trigrams
gin_trgm_ops keeps a list of rows for every trigram. gist_trgm_ops keeps a fixed-length signature of each
value’s trigrams, 12 bytes unless you set siglen, so it is lossy and the table rechecks more rows, but only GiST
can return rows nearest-first by the distance operator <->, a kNN scan. Both serve LIKE, ILIKE, ~, ~*
and %, and both add work to every insert and update of the column.
Checkpoint
Check: pg_trgm, similarity and GIN trigram indexes
When to pick full text, trigrams or plain ILIKE
The three tools answer different questions. Answer the table’s two questions about your page, and it rules out the tools that cannot do the job, with the plan or the number from the runs above:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Tool | Ranked relevance | Typo tolerance | ILIKE %term% | Phrase matching | Write cost |
|---|---|---|---|---|---|
| Full-text search (tsvector + GIN) Reads with: lexemes and positions | Good ts_rank_cd scored row 7 at 1.4952, ahead of 800 rows tied at 0.4310 | Poor running and runing are different lexemes; the stemmer merges forms, not misspellings | Poor a tsvector carries whole lexemes, not substrings | Good phraseto_tsquery matched the sentence on monsoon rains but not on rains monsoon | Mixed every lexeme of every row goes into the GIN index on insert and update |
| Trigrams (pg_trgm + GIN or GiST) Reads with: three-letter windows of the string | Poor similarity is one number per pair; it does not weigh a title above a body | Good similarity(postgresql, postgersql) = 0.46666667, above the 0.3 threshold | Good a Seq Scan became a Bitmap Index Scan on names_trgm; '%me%', with no trigram, did not | Poor a set of trigrams has no order; monsoon rains and rains monsoon look the same | Mixed every trigram of every row is written; long strings make many trigrams |
| Plain ILIKE, no index Reads with: a byte-by-byte match of every row | Poor no score; the rows come back in no particular order | Poor the pattern is literal; runing never matches running | Mixed the pattern works, but on 20,000 rows the plan is a Seq Scan | Mixed a literal phrase matches only the exact words and spacing that are stored | Good no index to maintain |
How the answers decide
- “What kind of search are you building?” Ranked results across paragraphs of text: rules out Plain ILIKE, no index and Trigrams (pg_trgm + GIN or GiST). Prose needs ranked relevance and phrases, which only full-text search gives.
- “What kind of search are you building?” Lookups by short strings (names, codes, emails) and “Do you need to match slight misspellings?” Yes, autocomplete or fuzzy lookup: rules out Full-text search (tsvector + GIN) and Plain ILIKE, no index. Short identifiers plus typos is what trigrams are for; a plain ILIKE on 20,000 rows plans a Seq Scan.
- “What kind of search are you building?” Lookups by short strings (names, codes, emails) and “Do you need to match slight misspellings?” No, exact words only: rules out Full-text search (tsvector + GIN) and adds a caveat to Plain ILIKE, no index. Exact lookups do not need lexemes; a B-tree may be smaller than a trigram index for this case.
- “What kind of search are you building?” Both, side by side on the same page: rules out Plain ILIKE, no index and adds a caveat to Full-text search (tsvector + GIN) and Trigrams (pg_trgm + GIN or GiST). Keep both indexes side by side; the query planner picks the one that answers each query.
Many pages run two of them side by side: a weighted tsvector with GIN for ranked results over titles and bodies, and
a trigram index on a short name column, such as an author or a city, for lookups that forgive typos. A plain prefix
such as LIKE 'ananya@%' needs neither: a B-tree can serve it, with the text_pattern_ops operator class when the
database does not use the C locale. Vector similarity search with pgvector
is a different kind again, for queries that match by meaning rather than by shared words.
Practise: a search box on 20,000 rows
Each question below can be answered from the four runs above: the sentence that became a tsvector, the search page of 20,000 articles, the Hindi sentence through two configurations, and the trigram index on names. Start with one of your own: why did row 7 score 1.4952 while 800 rows tied at 0.4310, and which second sort key would give a reader those 800 in an order that makes sense?
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
On a PostgreSQL 18 of your own
Take any table with two text columns, add a
GENERATED ALWAYS AS (setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', body), 'B')) STORED
column, and compare EXPLAIN (ANALYZE) of WHERE search @@ websearch_to_tsquery('english', 'your terms') before
and after CREATE INDEX ... USING GIN. Then add a trigram index on a name column and compare ILIKE '%part%'
with name % 'a misspelt name'.
Interview questions
Warm-up (fresher to 2–5 years): What does to_tsvector do, and why does it drop words such as the, for and and?
to_tsvector is PostgreSQL’s text normaliser. It takes a text search configuration and a string, splits the string
into tokens with the configuration’s parser, applies its dictionaries to each token, and returns a sorted set of
lexemes with the positions they came from. The english configuration uses the Snowball dictionary english_stem,
which reduces the forms of a word to one stem: running and runs both become run, and drying became dri. The
same dictionary carries the English stopword list: common words such as the, and, for and in appear in almost
every document and tell documents apart too little to be worth indexing, so they are dropped. In the run, 12 words
became 8 lexemes, and each kept its position, which is how phraseto_tsquery could tell monsoon rains from
rains monsoon.
Two harder questions turn this page into decisions an interviewer probes: configurations for a catalogue that mixes scripts, and indexes for a brand-name search box that must forgive typos:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A product catalogue holds about 60,000 rows. Half of each row's name is in English, the other half in Hindi, Marathi or Tamil, with no reliable language column. Users type any of the four, with their own typos. Which configuration (or configurations) do you build, and how do you prove the setup is right?
-
Mid to senior
A page over 500,000 brand names must answer two kinds of query: a suggest-as-you-type that forgives typos ('nkie' should find 'Nike') and an exact match on the brand slug. You may create at most one index per column. Which index do you create for each, and how do you prove each choice?
Warm-up (fresher to 2–5 years): Why does PostgreSQL need a tsquery and the @@ operator instead of plain LIKE?
Because matching words well needs a normalised form on both sides, and LIKE compares characters. LIKE '%running%'
misses a row that says runs, and because LIKE is case-sensitive it also misses RUNNING; it cannot rank what it
finds, and with a leading wildcard no B-tree can help it. A tsvector carries each lexeme once with its positions; a
tsquery combines lexemes with &, |, ! and <-> for word distance, and @@ returns true when the vector
satisfies the query. In the run, @@ matched the sentence to phraseto_tsquery('monsoon rains') because the two
lexemes were adjacent and in order, and did not match websearch_to_tsquery('quick -shoes') because the sentence
also carries shoes. A GIN index answers @@ by reading one posting list per lexeme, so a 20,000-row table needs no
sequential scan.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What do to_tsvector and the tsquery builders do?
to_tsvector turns text into a sorted set of lexemes with positions, dropping stopwords and stemming the rest. websearch_to_tsquery and plainto_tsquery build a valid tsquery from any user input, while to_tsquery errors on ordinary typing.
-
How is a full-text search page built?
Keep a weighted tsvector in a STORED generated column (a virtual one cannot be indexed in PostgreSQL 18), find rows through one GIN index, order by ts_rank_cd with a tie-breaker, and build ts_headline snippets only for the rows shown.
-
What does the text search configuration decide?
Which words are dropped as stopwords and how the rest are stemmed. The server ships simple, english, hindi, nepali, tamil and 25 others; a missing one is an error, and the right one is whichever does best on your own text.
-
Why does an index on unaccent need a wrapper?
unaccent is STABLE, so an index on it needs a small IMMUTABLE wrapper that names the function and its dictionary with their schema — and a REINDEX whenever the rules file changes.
-
How does pg_trgm match, and when does its index help?
It compares strings by their trigrams: % matches typos above the default 0.3 threshold, and a trigram index turns ILIKE '%term%' from a Seq Scan into a Bitmap Index Scan once the pattern holds three characters. Only GiST returns rows nearest-first.
-
When do you choose full text, trigrams or a B-tree?
Full text for ranked search over prose; trigrams for typo-tolerant lookups and fragment searches on short strings; a plain B-tree for a prefix. Many pages run the first two side by side.
Key takeaways
to_tsvectorturns text into a sorted set of lexemes with their positions, dropping stopwords and stemming the rest;websearch_to_tsqueryandplainto_tsquerybuild a valid tsquery from any user input, whileto_tsquerystops with a syntax error on ordinary typing.- A search page keeps a weighted tsvector in a
STOREDgenerated column, because a virtual one cannot be indexed in PostgreSQL 18, finds rows through one GIN index, orders them byts_rank_cdwith a tie-breaker, and buildsts_headlinesnippets only for the rows it shows. - The text search configuration decides which words are dropped and how the rest are stemmed; the server ships
simple,english,hindi,nepali,tamiland 25 others, a missing one is an error, and the right one is the one that does best on your own text. unaccentisSTABLE, so an index on it needs a smallIMMUTABLEwrapper that names the function and its dictionary with their schema, and aREINDEXwhenever the rules file changes.pg_trgmcompares strings by their trigrams:%matches typos above the default 0.3 threshold, a trigram index turnsILIKE '%term%'from a Seq Scan into a Bitmap Index Scan once the pattern holds three characters, and only GiST returns rows nearest-first.- Ranked search over prose calls for full text, typo-tolerant lookups and fragment searches on short strings for trigrams, and a plain prefix for a B-tree; many pages run the first two side by side.
Terms in this lesson
- lexeme
- A normalised form of a word that a text search configuration keeps, after lower-casing, stemming and stopword removal: under the english configuration 'runs' and 'running' both become 'run'.
- text search configuration
- A row of pg_ts_config that names a parser and a chain of dictionaries for text search: it decides which tokens the parser finds, which are dropped as stopwords and how the rest are stemmed to lexemes.
- trigram
- A three-letter window of a string, padded with two spaces at the start and one at the end. pg_trgm records every trigram of a string and compares strings by how many they share.
- tsquery
- A PostgreSQL data type that holds a search expression over lexemes, combined with and (&), or (|), not (!) and word distance (<N>), matched against a tsvector by the @@ operator.
- tsvector
- A PostgreSQL data type that holds a sorted set of lexemes, each with the word positions it came from, built from text by to_tsvector under a text search configuration.
References
- PostgreSQL 18 documentation: Chapter 12 Full Text Search (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 12.2 Tables and Indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 12.3 Controlling Text Search (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 12.4 Additional Features (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 12.6 Dictionaries (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 12.9 Preferred Index Types for Text Search (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 8.11 Text Search Types (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 9.13 Text Search Functions and Operators (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.35 pg_trgm (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.48 unaccent (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 5.4 Generated Columns (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: CREATE TABLE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: ALTER TABLE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: CREATE INDEX (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 11.2 Index Types (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 11.7 Indexes on Expressions (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 7.6 LIMIT and OFFSET (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 65.4 GIN Indexes (fast update and the pending list) (The PostgreSQL Global Development Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress