PostgreSQL Full-Text Search: Good Enough to Replace Elasticsearch?

Technical PM + Software Engineer
The ecosystem narrative that says "start with Postgres, then migrate to Elasticsearch when you scale" is everywhere. It is presented as inevitable: Postgres is a neat stopgap and Elasticsearch is the durable solution. That framing is too simplistic and often costly. For many production systems, PostgreSQL full-text search, plus a few extensions, gives a better tradeoff: fewer moving parts, stronger consistency, easier development, and competitive relevance for most user-facing search experiences.
This article gives an evidence-first decision framework, concrete implementation patterns, tuning knobs, and clear migration triggers for experienced full-stack JavaScript developers deciding between Postgres FTS and a dedicated search engine.
What Postgres FTS actually gives you
Postgres ships with mature FTS primitives:
tsvector: document representation with lexeme lists and positional info.tsquery: parsed user queries with logical operators and phrase support.- ranking via
ts_rankandts_rank_cd. - language configs and dictionaries for stemming and stopwords.
- GIN and GiST indexes on
tsvector. - snippet generation with
ts_headline. - helpful extensions like
pg_trgmfor fuzzy and substring search, andunaccentfor diacritics.
Collectively, that covers keyword search, phrase search, weighted fields, language-aware tokenization, snippet generation, and index-backed query execution. For many product search surfaces such as help centers, admin search, internal docs, and small-to-mid catalogs, that is a full-featured solution.
Implementation patterns that scale
If you choose Postgres as your long-term search layer, do it well up front. These patterns avoid the most common scaling and maintenance traps.
- Materialize a search vector per row with a generated column or trigger.
- Create a GIN index on that vector.
- Keep ranking deterministic by combining weighted field ranking with a secondary sort.
- Combine full-text search with your relational filters in the same query.
A generated column setup looks like this:
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'C')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
If you also need fuzzy matching on short names or misspellings, pair it with pg_trgm:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm
ON products USING GIN (name gin_trgm_ops);
Tradeoff: generated columns are simple and transparent, but they do increase write cost. If the indexed fields churn constantly, triggers or batched updates may be a better fit.
Query construction and relevance tuning
Choosing the right query parser and ranking strategy matters more than most teams expect.
plainto_tsqueryis safe and straightforward for raw user input.websearch_to_tsqueryis often the best UI-facing option because it handles quotes and familiar search behavior.phraseto_tsqueryis useful when exact phrase behavior matters.
A practical query that combines ranking with structured filters might look like this:
SELECT
id,
title,
ts_headline('english', description, q) AS snippet,
ts_rank_cd(search_vector, q, 32) AS rank
FROM products,
websearch_to_tsquery('english', $1) q
WHERE search_vector @@ q
AND tenant_id = $2
AND status = 'active'
ORDER BY rank DESC, updated_at DESC
LIMIT 20;
Important notes:
ts_rank_cdusually gives more stable ranking when document lengths vary.setweightlets titles outrank body text.- A deterministic secondary sort keeps the results from feeling random.
- Avoid exposing raw
tsquerysyntax directly to users.
Pitfall: mixing ILIKE and full-text search without a clear strategy often creates inconsistent ranking and bad plans. Keep token search and fuzzy search responsibilities explicit.
Relevance tuning that actually works
When teams say Postgres search feels weak, the engine is often not the problem. The tuning is.
What usually helps:
- field weighting through
setweight - document-length normalization via
ts_rank_cd - phrase boosts for exact or near-exact intent
- a small blend of rank with recency or popularity
For example:
SELECT
id,
title,
(0.6 * COALESCE(ts_rank_cd(title_vector, q), 0)) +
(0.3 * COALESCE(ts_rank_cd(desc_vector, q), 0)) +
(0.1 * popularity_score) AS score
FROM search_docs,
websearch_to_tsquery('english', $1) q
WHERE combined_vector @@ q
ORDER BY score DESC;
Tradeoff: hybrid scoring is more expressive, but harder to reason about and more expensive to maintain. Keep it explainable enough that your team can debug it.
Performance and maintenance realities
This is where teams usually get tripped up.
- GIN is usually the right default for FTS lookup speed, but it can become large and churn under heavy write patterns.
- GiST can be more update-friendly in some cases, though it is usually not the first choice for standard FTS.
pg_trgmis excellent for fuzzy matching, but it solves a different problem thantsvector.- Query plans matter. Use
EXPLAIN ANALYZE, not intuition.
A typical inspection path:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM products
WHERE search_vector @@ websearch_to_tsquery('english', 'foo bar')
AND tenant_id = 123;
Maintenance checklist:
- monitor GIN index size and bloat
- vacuum regularly
- reindex when necessary
- watch tail latency, not just averages
- benchmark with realistic filters and concurrency
Pitfall: people often judge Postgres FTS based on a development-sized dataset or a single happy-path query. Measure p95 and p99 under real conditions.
When Elasticsearch is actually the right answer
There are real cases where a dedicated search engine is justified.
You should start taking Elasticsearch or OpenSearch seriously when you need several of these at once:
- advanced fuzzy tolerance across very large corpora
- highly customized analyzers and token pipelines
- large-scale faceting and aggregation workloads
- heavy relevance experimentation at scale
- horizontal scaling beyond what a single primary database node should carry
A reasonable rule of thumb: if you are working with tens of millions of text-heavy documents and you need consistently low tail latency for fuzzy queries and aggregation-heavy search, start prototyping a dedicated search cluster.
Tradeoff: the moment you adopt Elasticsearch, you inherit more infrastructure, more eventual-consistency decisions, more reindexing work, and more operational surface area.
A practical decision framework
Ask measurable questions instead of following the default industry narrative.
- Can weighted Postgres FTS plus
pg_trgmachieve acceptable relevance on real user queries? - What are the p50, p95, and p99 latencies under realistic concurrency?
- Do you need strict read-after-write behavior in search results?
- Does your team actually want to operate another distributed system?
- Are there specific search-engine features you genuinely need, or are you importing complexity because it sounds scalable?
If most of the answers point toward simplicity, strong consistency, and modest relevance needs, Postgres is probably the right long-term choice.
Common mistakes that make Postgres look worse than it is
Avoid these:
- indexing everything without field weights
- using the wrong query parser for the user experience
- ignoring language configs and accent handling
- mixing FTS and wildcard matching without clear intent
- failing to measure tail latency
- neglecting GIN maintenance
These are not edge cases. They are exactly why otherwise capable teams conclude too early that Postgres search is the problem.
Hybrid and migration strategy
You do not need to make a permanent decision on day one.
A pragmatic path:
- build a clean search abstraction in your app
- implement it with Postgres first
- instrument relevance and performance deeply
- prototype a dedicated search engine only if metrics show sustained need
- migrate behind a switch rather than a full rewrite
A lightweight asynchronous indexing sketch might look like this:
await db.query('BEGIN');
await db.query('UPDATE products SET ... WHERE id = $1', [id]);
await db.query("NOTIFY search_rebuild, $1", [id]);
await db.query('COMMIT');
// separate worker consumes notifications and updates a dedicated search index if needed
Avoid synchronous dual-writes to a search cluster from the request path unless you genuinely need that complexity.
Before you add another search system
Before you introduce a dedicated search engine, validate this:
- you have tuned Postgres FTS with real weighting and ranking
- you have measured performance with realistic concurrency
- you know whether freshness and consistency matter more than advanced search features
- you have a concrete feature gap that Postgres cannot close
- your team is ready to operate the extra system
If most of those checks are not in place yet, Postgres FTS is not just a stopgap. It is likely the pragmatic, maintainable choice.
Postgres full-text search is not perfect. But it is far more capable than many teams give it credit for. For a huge class of applications, it gives you fewer systems to run, stronger consistency, and more than enough search quality when you invest in the fundamentals. Start with the boring thing that works. Add complexity only when the evidence says you need it.