Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

One sentence, a tsvector and 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:

Interactive · step by step From text to lexemes to a ranked list of rows Step through the write path on the left and the read path on the right, and watch the two meet at the GIN index.
From text to lexemes to a ranked list of rows, step 6 of 6. Showing Article text (Running shoes…), User's text, to_tsvector (title A, body B), Query parser ('monsoon' & 'run'), tsvector ('run':1 'shoe':2 …), GIN index (monsoon: 1–4, 7…), Ranked rows (7, then 25, 50). In focus: Ranked rows, GIN index.Article textRunning shoes…User's textmonsoon runningto_tsvectortitle A, body BQuery parser'monsoon' & 'run'tsvector'run':1 'shoe':2 …GIN indexmonsoon: 1–4, 7…Ranked rows7, then 25, 50

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 to_tsvector('english', 'Running shoes for the monsoon rains in Mumbai: waterproof and quick drying') returned eight lexemes from the sentence's twelve words. Why four fewer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The four stopwords for, the, in and and were dropped; each of the other eight words became one lexeme

    The english configuration sends each word to english_stem, which drops the words on its stopword list and stems the rest. The run's vector holds eight lexemes at positions 1, 2, 5, 6, 8, 9, 11 and 12; positions 3, 4, 7 and 10 were for, the, in and and.

  2. Question 2 of 2 A search box receives this text from a user, 'cheap "monsoon running" shoes'. Which function should you call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: websearch_to_tsquery('english', ...), which accepts the quoted phrase and never raises a syntax error

    websearch_to_tsquery reads quoted phrases, the word or between terms and a leading - that excludes a word, and it accepts text that would be a syntax error for to_tsquery.

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:

A GIN index on a weighted tsvector, with ts_rank_cd and ts_headline

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

  1. Question 1 of 2 A colleague asks why the lesson's search column says STORED, now that PostgreSQL 18 has virtual generated columns. What is the reason?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A virtual generated column cannot be indexed in PostgreSQL 18, and the search needs a GIN index on the column

    In PostgreSQL 18 a generated column without STORED is virtual by default, computed when it is read. A search column needs its index, so it stays STORED; a trigger that rebuilds the vector on every write is the older way, with the same storage cost.

  2. Question 2 of 2 In the run, row 7 scored ts_rank_cd 1.4952 while 800 other matching rows tied at 0.4310. Every one of them has monsoon and run in its title (weight A) and in its body (weight B). What explains the gap?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Row 7 has the two words side by side in its title and in its body, and ts_rank_cd rewards query lexemes that sit close together

    With the default weights {0.1, 0.2, 0.4, 1.0} an A lexeme counts 1.0 and a B lexeme 0.4, but all 801 matches carry the same weights, so the gap comes from proximity: ts_rank, which ignores it, gave 0.9985 and 0.9875. The 800 tied rows also need a tie-breaker in the ORDER BY, which is why the query adds id.

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:

pg_ts_config, two configurations on a Hindi sentence, and unaccent

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

  1. Question 1 of 2 The lesson's Hindi sentence 'मुंबई में अच्छे बरसाती जूते' became five lexemes under 'simple' and five under 'hindi'. What is different about the two results?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The hindi configuration stemmed each word, so 'मुंबई' became 'मुंब' and 'जूते' became 'जूत'; simple kept the forms as they were

    A stemmer finds more forms of a word: in the run a search for जूता matched जूते under hindi and not under simple. It can also merge words a reader would keep apart, so compare both configurations on your own documents and searches.

  2. Question 2 of 2 You want an expression index on lower(unaccent(name)). CREATE INDEX fails. The run read provolatile from pg_proc and found 's' for unaccent. What does that tell you to do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: unaccent is STABLE, so wrap it in a small SQL function declared IMMUTABLE that names the function and the dictionary with their schema, then index the wrapper

    A dictionary's rules can change, which is why unaccent is STABLE and not IMMUTABLE. The wrapper's IMMUTABLE label is a promise PostgreSQL does not check, so the index needs a REINDEX whenever the rules file changes.

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:

show_trgm, similarity and a GIN trigram index for ILIKE wildcards

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

  1. Question 1 of 2 Before the GIN trigram index, EXPLAIN (COSTS OFF) of SELECT ... WHERE name ILIKE '%meht%' showed a Seq Scan. After CREATE INDEX ... USING GIN (name gin_trgm_ops), the same query showed a Bitmap Index Scan on names_trgm. Which option is the reason?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The GIN index lists the rows of every trigram, so the planner reads only the rows that hold the trigrams of the pattern, meh and eht

    The Bitmap Heap Scan then rechecks the ILIKE on the heap, because holding the pattern's trigrams is necessary but not sufficient for matching the pattern.

  2. Question 2 of 2 The run's SHOW pg_trgm.similarity_threshold printed the server's default. What is that value?

    Type a number.

    A hint

    It is the value above which the % operator returns true.

    Show the answer: Question 2 of 2

    Answer: 0.3 (anything from 0.299 to 0.301 counts)

    0.3 is the default, and similarity('postgresql', 'postgersql') of 0.46666667 was above it, so the % operator returned true. The threshold can be set per session with SET pg_trgm.similarity_threshold.

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:

Interactive · answer and compare Full text, trigrams or plain ILIKE? Answer two questions about what your search page needs, and the table rules out the tools that cannot do it well.
What kind of search are you building?
Do you need to match slight misspellings?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three tools against five questions a search page asks of them
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.

  1. Question 1 of 11 The sentence's tsvector held 'monsoon':5 'rain':6, and phraseto_tsquery('english', 'monsoon rains') matched, while phraseto_tsquery('english', 'rains monsoon') did not. Why does the order matter?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: phraseto_tsquery joins lexemes with <->, which matches only when the first lexeme's position is one less than the second's

    Phrase matching is one of the reasons full-text search keeps positions and not only a set of lexemes; a trigram set would not have told the two phrases apart.

  2. Question 2 of 11 A user types '"monsoon rains or snow', with an opening quote and no closing one, into a search box. The server calls to_tsquery with that text. What happens, and what should the page do instead?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: to_tsquery raises a syntax error; the page should call websearch_to_tsquery, which accepts the text and builds a reasonable query

    Both websearch_to_tsquery and plainto_tsquery accept any text; use them on anything that comes from a user.

  3. Question 3 of 11 A product page needs the search column to include the brand name as weight A beside the product title. The column is a STORED generated tsvector with a GIN index. What is the simplest change in PostgreSQL 18?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: ALTER TABLE ... ALTER COLUMN search SET EXPRESSION AS (...) with the new expression

    ALTER TABLE ... SET EXPRESSION AS takes an ACCESS EXCLUSIVE lock and, for a STORED column, rewrites every row, so on a large table plan it for a quiet hour.

  4. Question 4 of 11 Why does ts_headline take the original title and body as its first argument, not the stored tsvector?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: The tsvector no longer holds the original words, their case or their order; ts_headline needs the text to build a readable snippet

    Pass ts_headline the same configuration as to_tsvector, or the words it marks will not match the lexemes of the query, and compute it only for the rows on screen: three in the run, not 801.

  5. Question 5 of 11 A news site in Kathmandu keeps its articles in Nepali, written in Devanagari. All you know about the server is the pg_ts_config list from the run. Which configuration do you try first, and what do you check before you keep it?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: nepali, because the server lists it; then compare its search results with those of simple on real articles and real searches

    Read the list first, then measure: the configuration whose top results hold what readers searched for is the one to keep.

  6. Question 6 of 11 Someone edits the unaccent rules file so that ö becomes oe instead of o. Which action does your index on lower(f_unaccent(name)) now need?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: REINDEX the index, because the IMMUTABLE wrapper promised an output that has now changed

    Treat a change to the rules file as a maintenance step: change it, run REINDEX, and plan it like any other release.

  7. Question 7 of 11 The default pg_trgm.similarity_threshold is 0.3. You raise it in a session to 0.5. Does 'postgresql' % 'postgersql' now return true? Answer 1 for yes and 0 for no.

    Type a number.

    A hint

    The run reported similarity('postgresql', 'postgersql') = 0.46666667.

    Show the answer: Question 7 of 11

    Answer: 0

    0.46666667 is above 0.3 but below 0.5, so the % operator returns false at the higher threshold. SET pg_trgm.similarity_threshold is useful for a quick experiment; keep the default for application code.

  8. Question 8 of 11 A support screen lists the ten customer names closest to what an agent types, nearest first, from the lesson's 20,000 names, with ORDER BY name <-> 'Nisha Mehtaa' LIMIT 10. Which index lets PostgreSQL read only the nearest rows?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: A GiST index with gist_trgm_ops, because only GiST can return rows in order of the <-> distance

    GiST keeps a signature of each name's trigrams and can walk the index nearest-first, which is what an ORDER BY with <-> and a LIMIT asks for; GIN keeps a list of rows per trigram and has no order to offer.

  9. Question 9 of 11 With the GIN trigram index names_trgm in place, the run planned name ILIKE '%meht%' as a Bitmap Index Scan but name ILIKE '%me%' as a Seq Scan. Why?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: 'me' holds no complete trigram, so the index has nothing to look up

    pg_trgm extracts the trigrams of a pattern's literal parts and looks them up. The manual warns that a pattern with no extractable trigrams degenerates to a full-index scan, so the planner reads the table instead.

  10. Question 10 of 11 Which page is best served by a plain B-tree on the column, with no GIN or trigram index at all?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A prefix search for emails that start with 'ananya@', run as WHERE email LIKE 'ananya@%'

    Pick the smallest tool that answers the question: a B-tree for prefix lookups, trigrams for typos and '%term%' wildcards, and full text for ranked results on prose.

  11. Question 11 of 11 Which of these pay a write cost on every INSERT and UPDATE of the indexed column?

    Choose every answer that is right.

    Show the answer: Question 11 of 11

    Answer:

    • A GIN index on a tsvector column
    • A STORED generated tsvector column
    • A GIN trigram index on a text column

    Count the sum of these costs against the queries they let you avoid; a column that is written often and searched rarely does not deserve an index.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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_tsvector turns text into a sorted set of lexemes with their positions, dropping stopwords and stemming the rest; websearch_to_tsquery and plainto_tsquery build a valid tsquery from any user input, while to_tsquery stops with a syntax error on ordinary typing.
  • A search page keeps a weighted tsvector in a STORED generated column, because a virtual one cannot be indexed in PostgreSQL 18, finds rows through one GIN index, orders them by ts_rank_cd with a tie-breaker, and builds ts_headline snippets 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, tamil and 25 others, a missing one is an error, and the right one is the one that does best on your own text.
  • 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.
  • pg_trgm compares strings by their trigrams: % matches typos above the default 0.3 threshold, a trigram index turns ILIKE '%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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.