๐ŸŒ Detecting your locationโ€ฆ

How to Build a Full-Text Search Feature with PostgreSQL in 2026

โฑ๏ธ5 min read  ยท  1,055 words

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.

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.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work โ€” which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

โœ๏ธ Leave a Comment

Your email address will not be published. Required fields are marked *