Adding search to your application often makes developers reach for Elasticsearch โ but PostgreSQL has powerful built-in full-text search that handles most needs without extra infrastructure. This guide shows how to build production search with PostgreSQL, from basics to ranking and highlighting.
๐ Table of Contents
- Why Use PostgreSQL for Search?
- Understanding tsvector and tsquery
- Basic Full-Text Search
- Adding a Precomputed tsvector Column with Index
- Ranking Results by Relevance
- Highlighting Matched Terms
- Using It from Node.js
- Adding Fuzzy/Typo-Tolerant Search
- When to Use Elasticsearch Instead
- Frequently Asked Questions
- Conclusion
Why Use PostgreSQL for Search?
- No extra infrastructure: Use your existing database โ no separate search engine to run and sync
- Good enough for most apps: Handles millions of documents with proper indexing
- Transactional consistency: Search index updates with your data, no sync lag
- Rich features: Ranking, stemming, highlighting, multiple languages built in
Understanding tsvector and tsquery
-- tsvector: a processed, searchable representation of text
-- tsquery: a search query
SELECT to_tsvector('english', 'The quick brown foxes are jumping');
-- 'brown':3 'fox':4 'jump':6 'quick':2
-- Note: stemming (foxes->fox, jumping->jump) and stopword removal (the, are)
SELECT to_tsvector('english', 'The quick brown foxes')
@@ to_tsquery('english', 'fox');
-- true - matches because 'foxes' stems to 'fox'
Basic Full-Text Search
-- Search articles by title and content
SELECT id, title
FROM articles
WHERE to_tsvector('english', title || ' ' || content)
@@ to_tsquery('english', 'postgresql & search');
-- & means AND, | means OR, ! means NOT
-- plainto_tsquery handles user input safely (treats as AND)
SELECT id, title
FROM articles
WHERE to_tsvector('english', title || ' ' || content)
@@ plainto_tsquery('english', 'postgresql search tutorial');
-- websearch_to_tsquery supports Google-like syntax (quotes, OR, -)
SELECT id, title
FROM articles
WHERE to_tsvector('english', content)
@@ websearch_to_tsquery('english', '"full text" search -elasticsearch');
Adding a Precomputed tsvector Column with Index
Computing tsvector on every query is slow. Store it in a column with a GIN index:
-- Add a generated tsvector column (auto-updates with the data)
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, ''))
) STORED;
-- Create a GIN index for fast searching
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
-- Now searches are fast and use the index
SELECT id, title FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'postgresql search');
Ranking Results by Relevance
-- ts_rank scores how well each row matches
SELECT id, title,
ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', 'postgresql search') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
-- Weight title matches higher than content
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(content, '')), 'B')
) STORED;
-- 'A' weight (title) ranks higher than 'B' weight (content)
Highlighting Matched Terms
-- ts_headline returns snippets with matched terms highlighted
SELECT id, title,
ts_headline('english', content, query,
'StartSel=, StopSel=, MaxWords=35, MinWords=15') AS snippet
FROM articles, plainto_tsquery('english', 'postgresql') query
WHERE search_vector @@ query
ORDER BY ts_rank(search_vector, query) DESC;
-- snippet contains the relevant excerpt with around matches
Using It from Node.js
app.get('/search', async (req, res) => {
const q = req.query.q;
if (!q) return res.json({ results: [] });
const result = await db.query(`
SELECT id, title,
ts_headline('english', content, query,
'StartSel=,StopSel=,MaxWords=35') AS snippet,
ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', $1) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20
`, [q]);
res.json({ results: result.rows });
});
Adding Fuzzy/Typo-Tolerant Search
-- Enable the pg_trgm extension for fuzzy matching (handles typos)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Trigram index for similarity search
CREATE INDEX articles_title_trgm ON articles USING GIN (title gin_trgm_ops);
-- Find titles similar to a (possibly misspelled) query
SELECT title, similarity(title, 'postgres serch') AS sim
FROM articles
WHERE title % 'postgres serch' -- % is the similarity operator
ORDER BY sim DESC;
-- Matches 'PostgreSQL search' despite the typo
When to Use Elasticsearch Instead
PostgreSQL full-text search handles most applications well. Consider Elasticsearch when you need:
- Very large scale (hundreds of millions of documents) with complex relevance tuning
- Advanced features: faceted search, complex aggregations, geo-search at scale
- Search across many data sources beyond your database
- Real-time analytics on search data
For most apps โ blogs, e-commerce, content sites, SaaS โ PostgreSQL search is simpler, requires no extra infrastructure, and is more than sufficient.
Frequently Asked Questions
Q: Is PostgreSQL search good enough, or do I need Elasticsearch?
A: For most applications, PostgreSQL full-text search is more than sufficient โ it handles millions of documents with ranking, highlighting, and stemming. Use Elasticsearch only for very large scale, advanced faceted search, or search across many data sources. Don’t add Elasticsearch infrastructure prematurely.
Q: How do I handle typos in search?
A: Use the pg_trgm extension for trigram similarity matching, which tolerates typos and misspellings. Combine it with full-text search โ use tsvector for the main search and trigram similarity as a fallback or for autocomplete/suggestions.
Q: Why is my full-text search slow?
A: You’re probably computing to_tsvector on every query without an index. Add a stored generated tsvector column with a GIN index. This precomputes the searchable representation and makes searches fast even on large tables.
Q: plainto_tsquery vs to_tsquery vs websearch_to_tsquery?
A: to_tsquery requires operator syntax (&, |) โ good for programmatic queries. plainto_tsquery handles plain user input (treats words as AND) โ safe for user search boxes. websearch_to_tsquery supports Google-like syntax (quotes, OR, -) โ best for user-facing search.
Q: Can I search in multiple languages?
A: Yes โ PostgreSQL has text search configurations for many languages (english, spanish, french, etc.) that handle stemming and stopwords per language. Specify the language in to_tsvector/to_tsquery. Store a language column if your content is multilingual.
Conclusion
PostgreSQL’s built-in full-text search handles most applications’ search needs without the complexity of a separate search engine. Use a stored generated tsvector column with a GIN index for performance, ts_rank for relevance ranking, ts_headline for highlighting, and pg_trgm for typo tolerance. Weight important fields (like titles) higher with setweight, and use plainto_tsquery or websearch_to_tsquery for safe user input. Reserve Elasticsearch for very large scale or advanced faceted search. For blogs, e-commerce, content sites, and most SaaS apps, PostgreSQL search is simpler, needs no extra infrastructure, stays consistent with your data, and performs excellently โ a great default choice before reaching for dedicated search infrastructure.
๐ You might also like
๐ Share this article




โ๏ธ Leave a Comment