Postgres by Example: Full-Text Search
June 22, 2026 · View on GitHub
PostgreSQL has a competent, built-in full-text search engine — no Elasticsearch, no Solr needed for most apps. The core abstractions are tsvector (a parsed-and-normalized representation of a document) and tsquery (a parsed search expression). With a GIN index on a tsvector, you get fast text search with stemming, ranking, and multi-language support, all in plain SQL.
What you'll learn:
to_tsvectorandto_tsquery- The
@@operator that tests a vector against a query - Configuring language (English, simple, etc.)
- Indexing with GIN for fast search
- Ranking results with
ts_rank
-- Setup
DROP TABLE IF EXISTS docs_fts;
CREATE TABLE docs_fts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL
);
INSERT INTO docs_fts (title, body) VALUES
('Postgres tips', 'PostgreSQL is a powerful relational database with full-text search built in.'),
('SQL joins', 'Inner joins keep matching rows; left joins keep all left rows.'),
('Indexing', 'B-tree indexes speed up equality and range queries. GIN indexes are great for arrays and text search.'),
('Cats', 'Cats are not databases. This sentence exists to add noise.');
-- to_tsvector parses, normalizes, and removes stop words
SELECT to_tsvector('english', 'PostgreSQL has powerful full-text search') AS v;
-- to_tsquery turns a query string into a tsquery
SELECT to_tsquery('english', 'powerful & search') AS q;
-- @@ tests "does this document match this query?"
SELECT title
FROM docs_fts
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'powerful & search');
-- websearch_to_tsquery: parses Google-style input (quotes, OR, -minus)
SELECT title
FROM docs_fts
WHERE to_tsvector('english', title || ' ' || body) @@ websearch_to_tsquery('english', 'index OR join -cats');
-- Add a generated tsvector column + GIN index for fast search at scale
ALTER TABLE docs_fts ADD COLUMN search_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX docs_fts_search_idx ON docs_fts USING gin (search_tsv);
-- Rank results
SELECT title, ts_rank(search_tsv, websearch_to_tsquery('english', 'database search')) AS rank
FROM docs_fts
WHERE search_tsv @@ websearch_to_tsquery('english', 'database search')
ORDER BY rank DESC;
DROP TABLE IF EXISTS docs_fts;
to_tsvector('english', text) tokenizes the input, lowercases it, applies the English stemmer (so running and runs map to the same root), removes stop words (the, a, is), and returns a tsvector — a sorted list of normalized tokens with positions. Different configurations (english, simple, german, russian, ...) implement different stemming and stop-word rules.
to_tsquery parses the expression: & (and), | (or), ! (not), <-> (followed by). websearch_to_tsquery accepts a friendlier syntax — double-quoted phrases, OR keyword, - to exclude. The latter is what you want behind a user-facing search box.
The @@ operator tests whether a tsvector matches a tsquery. With a GIN index on the tsvector, this is the fast path PostgreSQL uses for text search. The pattern above uses a generated column that always reflects title || ' ' || body — no triggers needed, no risk of forgetting to update.
To run:
$ psql -f source/full-text-search.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 4
v
---------------------------------------------------------------
'built':9 'database':5 'full-text':7 'postgresql':1 'powerful':3 'relat':4 'search':8
(1 row)
...
Common pitfalls:
- Calling
to_tsvector('english', ...)in theWHEREclause without an expression index defeats indexing. Either index the expression or use a generated column. - Choosing the wrong language:
to_tsvector('english', russian_text)produces meaningless stems. Match the configuration to the data. LIKE '%foo%'is not full-text search. It is substring matching, slow without trigram indexes, and does not understand stemming or stop words. Use full-text search for natural-language queries.- For multi-language data, you may need to store the language alongside the text and parameterize the configuration.
Tip: For substring or fuzzy matching that does not need stemming, the pg_trgm extension provides trigram-based similarity (similarity(a, b)) and an index that supports LIKE '%foo%' efficiently. Often used alongside full-text search.
Try it: Add more documents and search for them. Use <-> for phrase queries: to_tsquery('english', 'full <-> text') matches "full text" specifically. Add ts_headline('english', body, query) to highlight the matching snippet of each result.
Source: full-text-search.sql
Next: VACUUM and ANALYZE
Home: Postgres by Example