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 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. For semantic search alongside keyword search, the pgvector guide completes the picture.

Was this helpful?
Series · Part 3 of 4

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

  4. Part 4

    pgvector: Using Postgres as a Vector Database

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.