PostgreSQL has full-text search built in. For many applications, it's enough — no separate Elasticsearch cluster, no syncing infrastructure. The same database that holds the data does the search.
This page covers when it's the right choice and how to use it.
PostgreSQL stores searchable text as tsvector (tokenized; sorted; deduplicated):
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
Stop words removed; words stemmed; positions tracked.
SELECT * FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'fox & dog');
The @@ operator matches a tsvector against a tsquery.
For performance, store the tsvector:
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
Now searches don't recompute the vector each time.
For fast lookup:
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
GIN indexes for tsvector are the standard.
Rank results by relevance:
SELECT title, body,
ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'fox & dog') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
ts_rank produces a relevance score. Higher rank = more relevant.
-- Phrase
to_tsquery('english', 'quick <-> brown') -- "quick" immediately followed by "brown"
-- Proximity
to_tsquery('english', 'quick <2> dog') -- within 2 words
For exact phrase matching.
to_tsvector('spanish', body)
to_tsvector('french', body)
PostgreSQL ships with stemming dictionaries for major languages. For others, additional dictionaries available.
Show matched terms in results:
SELECT ts_headline('english', body, query, 'StartSel=<b>, StopSel=</b>')
FROM articles, to_tsquery('english', 'fox') query
WHERE search_vector @@ query;
Returns body with matched terms wrapped in <b> tags.
For typical CRUD apps with search, this is enough.
See ElasticsearchFundamentals.
SELECT title, body,
ts_rank(
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B'),
query
) AS rank
FROM articles, to_tsquery('english', 'fox') query;
Title matches weighted higher than body.
PostgreSQL's pg_trgm extension provides trigram-based similarity:
CREATE EXTENSION pg_trgm;
SELECT * FROM articles
WHERE title % 'foks'; -- "%" is similarity operator
Useful for handling typos, partial matches.
For autocomplete: trigram for prefix matching; FTS for full-content.
PostgreSQL's JSONB columns can be searched with FTS too:
SELECT * FROM events
WHERE to_tsvector('english', data->>'description') @@ to_tsquery('english', 'fox');
For semi-structured data.
GIN indexes are slower to update than B-tree. For high-write workloads, consider GIN with fastupdate=on (writes go to a pending list; consolidated periodically).
GIN indexes are large. Plan storage accordingly.
Generated column (PostgreSQL 12+) recomputes on update; transparent. Trigger-based updates are flexible but require maintenance.
For modern PostgreSQL, generated columns are simpler.
Bulk loads: drop the index; load; recreate. Faster than incremental.
For a typical app needing search:
tsvector column with appropriate languagets_rank for orderingMost apps never need to migrate.