env.dev

Postgres Full-Text Search vs Elasticsearch

tsvector and a GIN index cover most search needs without Elasticsearch. Working SQL, ranking, typo tolerance with pg_trgm, BM25 via ParadeDB, and limits.

By env.dev Updated

Postgres is Enough series

Postgres has shipped full-text search — tokenization, stemming, stop words, ranking, highlighting — as a built-in since version 8.3 in 2008. For the search box on your product, docs, or dashboard, a tsvector column with a GIN index answers queries in milliseconds without a second system, and unlike an Elasticsearch cluster, the results are always transactionally consistent with your data: a deleted row can never linger in yesterday's index. The gap that used to justify Elasticsearch — BM25 relevance ranking — closed too: ParadeDB (9,000+ GitHub stars) runs BM25 scoring inside Postgres on the Tantivy engine. What remains genuinely Elasticsearch territory is log analytics at terabytes per day, which is a different problem from application search and the reason GitLab sells its Elasticsearch-backed "advanced search" as a separate premium capability.

TL;DR

  • tsvector + GIN index + websearch_to_tsquery covers most application search — no sync pipeline, no cluster, results consistent with your transactions.
  • pg_trgm adds typo tolerance and fast ILIKE '%…%'; prefix queries (term:*) cover autocomplete.
  • Built-in ts_rank is frequency-based, not BM25 — ParadeDB's pg_search closes that gap inside Postgres.
  • The hidden cost of Elasticsearch is not the cluster — it is the pipeline that keeps it in sync with your database, and every consistency bug that pipeline breeds.
  • Elasticsearch still wins for log/observability workloads and heavy faceted aggregations at scale.

Can Postgres full-text search replace Elasticsearch?

Split the question by workload. Application search — users searching products, tickets, articles, messages — is a few million rows, short queries, and an expectation that results reflect the current state of the data. Postgres handles this directly. Log analytics — engineers slicing terabytes of append-only events by a dozen dimensions — is what the ELK stack was actually built for. Most teams adopt Elasticsearch for the first workload, then discover they are running a three-node cluster and a reindexing pipeline for a search box.

The pipeline is the real tax. Search in a separate system means every write must reach two places, so you add change-data-capture or dual writes, then reconciliation jobs for when they drift, then alerting for the reconciliation jobs. Search inside Postgres deletes that entire layer — the same consolidation argument that applies to queues and caches, with consistency as the bonus: search results can never reference a row your transaction rolled back.

How does tsvector search actually work?

Postgres preprocesses text into a tsvector of normalized lexemes — parsing tokens, folding case, stemming ("running" → "run"), and dropping stop words. Store it as a generated column so it maintains itself, index it with GIN, and query it with websearch_to_tsquery, which accepts Google-style syntax (quoted phrases, -exclusions, OR) instead of requiring you to build boolean expressions:

Self-maintaining search column, index, and ranked query
ALTER TABLE articles ADD COLUMN search tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
  ) STORED;

CREATE INDEX articles_search_idx ON articles USING gin (search);

SELECT id, title,
       ts_rank(search, query) AS rank,
       ts_headline('english', body, query) AS snippet
FROM articles, websearch_to_tsquery('english', 'postgres "full text" -mysql') AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;

setweight makes title hits outrank body hits, ts_rank orders by relevance, and ts_headline returns highlighted snippets — the three features people assume require a search engine. On a few hundred thousand rows this runs in single-digit milliseconds with a warm GIN index. More SQL patterns live in the PostgreSQL cheat sheet.

How do you handle typos and autocomplete?

Stemming does not forgive misspellings — "postgers" matches nothing. The pg_trgm extension fixes that by comparing three-character sequences: the % operator returns rows above a similarity threshold (default 0.3), and a trigram GIN index also accelerates the ILIKE '%term%' queries that are otherwise sequential scans:

Typo-tolerant lookup with pg_trgm
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm ON articles USING gin (title gin_trgm_ops);

SELECT title, similarity(title, 'postgers full text serach') AS sml
FROM articles
WHERE title % 'postgers full text serach'
ORDER BY sml DESC
LIMIT 5;

For autocomplete, prefix-match the last word with to_tsquery('english', 'postg:*') and debounce on the client. The practical recipe most applications land on: tsvector for the main query, pg_trgm as the "did you mean" fallback when it returns nothing.

What if I need BM25 ranking or better relevance?

The honest weakness of built-in search is ranking quality. ts_rank scores by term frequency and proximity but ignores document length and corpus-wide term rarity — the two ingredients that make BM25 (Elasticsearch's default since 2016) rank a short, precise match above a long document that mentions the term often. ParadeDB closes exactly this gap: its pg_search extension embeds the Rust search engine Tantivy in Postgres, adding BM25 scoring, custom tokenizers, and highlighting as a CREATE EXTENSION instead of a second datastore. For multilingual corpora — especially CJK languages the default parser tokenizes poorly — PGroonga does the same job. Extensions are the escalation path: you exhaust built-in FTS, then add an extension, and only then consider leaving Postgres — the same ladder the queue guide climbs from raw SKIP LOCKED to pgmq.

When is Elasticsearch actually the right choice?

  • Observability and log analytics — ingesting terabytes a day of append-only events and aggregating across dozens of fields is the workload Elasticsearch's inverted indexes, shard model, and Kibana were designed around. Postgres is the wrong shape for this.
  • Search as the product at scale — GitLab runs its advanced search on Elasticsearch: cross-project search over hundreds of millions of documents, operated by a dedicated team. If search relevance is a competitive feature with headcount attached, the specialist tool earns its cluster.
  • Heavy faceted aggregations — sub-second facet counts across many high-cardinality dimensions on large datasets favor purpose-built aggregation engines.

The symmetry with the rest of the series holds: the exceptions are named, measurable workloads — not "we might need better search someday". Start in Postgres, escalate on evidence.

References

This is part 3 of the Postgres is Enough series — Just Use Postgres makes the full argument, and the queue guide covers background jobs the same way.

Was this helpful?
Series · Part 3 of 3

Postgres is Enough

A series on Postgres as the default for queues, caching, search, vectors, and more.

  1. Part 1

    Just Use Postgres: One Database for Almost Everything

  2. Part 2

    Postgres as a Message Queue with SKIP LOCKED

  3. Part 3 · You are here

    Postgres Full-Text Search vs Elasticsearch

Frequently Asked Questions

Is Postgres full-text search good enough for a production application?

Yes, for the application-search workload: products, articles, tickets, users. tsvector with a GIN index delivers millisecond queries on hundreds of thousands of rows, with stemming, ranking, phrase search, and highlighting built in — and results are always consistent with your transactions because there is no separate index to sync.

What is the difference between to_tsquery and websearch_to_tsquery?

to_tsquery expects a boolean expression ('postgres & (search | fts)') and errors on malformed input, which makes it unsafe for raw user text. websearch_to_tsquery (Postgres 11+) accepts search-engine syntax — quoted phrases, minus for exclusion, or — and never throws on user input. Use websearch_to_tsquery for anything wired to a search box.

How do I make Postgres search tolerate typos?

Built-in FTS stems words but does not correct misspellings. Add the pg_trgm extension: it compares three-character sequences, so 'postgers' scores high similarity to 'postgres'. Query with the % operator, order by similarity(), and back it with a GIN index using gin_trgm_ops. A common pattern runs tsvector search first and falls back to trigram similarity when it returns no rows.

Does Postgres support BM25 ranking like Elasticsearch?

Not natively — ts_rank uses term frequency and proximity without corpus-wide statistics. The ParadeDB pg_search extension adds true BM25 scoring inside Postgres, built on the Tantivy engine, and is the standard answer when built-in ranking quality stops being acceptable. It installs as an extension, so your data stays in one database.

When should I move from Postgres to Elasticsearch?

When the workload changes shape, not just size: log and event analytics at terabytes per day, heavy faceted aggregations across many dimensions, or search as a core product feature with a team behind it — the pattern GitLab follows by running advanced search on Elasticsearch. For a growing application search box, exhaust tsvector, then ParadeDB, first.

Stay up to date

Get notified about new guides, tools, and cheatsheets.