# Postgres Full-Text Search vs Elasticsearch: When Postgres Is Enough (and When It Isn't)
TL;DR: For most applications — a docs site, an admin search box, a support ticket queue, a product catalog under a few million rows — Postgres full-text search (tsvector + a GIN index + websearch_to_tsquery) gets you ranked, typo-tolerant-enough search with zero extra infrastructure. Reach for Elasticsearch, OpenSearch, Meilisearch, or Typesense when you need true BM25 relevance tuning, faceted navigation at scale, instant search-as-you-type, or search traffic large enough that it would compete with your OLTP workload on the same database.
The question "postgres full text search vs elasticsearch" comes up the moment a team's LIKE '%query%' search starts returning garbage results in production order. This post walks through a correct Postgres FTS setup, where it genuinely falls short of a dedicated search engine, and how to decide between them without guessing.
Postgres full-text search vs Elasticsearch: the real difference
Postgres full-text search is a relational database feature: it tokenizes text into a tsvector, indexes it with GIN, and ranks matches with a built-in scoring function. Elasticsearch (and OpenSearch) is a dedicated search engine built on Apache Lucene, which uses BM25 as its default similarity/ranking algorithm since Elasticsearch 5.0.
The practical difference isn't "Postgres search is bad" — it's architectural. Postgres FTS runs inside your existing database: one system, one source of truth, transactional consistency for free. Elasticsearch is a second datastore: you get purpose-built relevance ranking, faceting, and horizontal scaling, but you also get a sync pipeline (CDC, dual writes, or a queue) to keep the search index consistent with Postgres, plus a second cluster to operate, patch, and pay for.
Postgres FTS keeps search and data in one system; an Elasticsearch setup adds a sync pipeline and a second place for data to drift out of sync.
Building correct Postgres full-text search
tsvector generated column and GIN index
The current, correct pattern (Postgres 12+) is a generated tsvector column so you never manually maintain search data with triggers:
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('english', coalesce(body, '')), 'C')
) STORED;
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);to_tsvector lowercases, strips stop words, and stems each field ('english' here is the text search configuration, swap it for your content's language). setweight tags each field's lexemes as A/B/C/D so title matches can outrank body matches later. The || operator concatenates the three weighted vectors into one. Because the column is GENERATED ... STORED, Postgres recomputes and re-indexes it automatically on every INSERT/UPDATE — no triggers, no drift between the source text and the index.
A GIN (Generalized Inverted Index) is the recommended index type for full-text search: it maps each lexeme to the rows containing it, which is what makes @@ lookups fast on large tables.
Querying with websearch_to_tsquery and ts_rank
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles, websearch_to_tsquery('english', 'postgres "full text search" -mysql') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;websearch_to_tsquery is the function to expose to end users: it parses web-search-style syntax — quoted phrases, - to exclude a term, bare words treated as AND — and, critically, it never throws a syntax error on malformed input, unlike to_tsquery, which expects well-formed operator syntax and will reject a raw search box string. ts_rank scores each match by how many of the weighted lexemes it hits and how they were weighted; ts_rank_cd additionally accounts for proximity between matched terms if you need phrase-adjacency to matter more.
Postgres full-text search vs LIKE
LIKE '%term%' does substring matching with no tokenization, no stemming, no relevance ranking, and no index support for a leading wildcard, so it forces a sequential scan on anything but a small table. tsvector/tsquery search is a different mechanism entirely: it matches on stemmed words (so "running" matches "run"), ranks results, and uses a GIN index, so it scales to large tables where LIKE '%term%' won't. The one thing LIKE still wins at: literal substring or prefix matching (SKUs, codes, IDs) where stemming would be wrong and you want exact character sequences — that's a job for a plain B-tree or pg_trgm, not tsvector.
Postgres full-text search vs trigram (pg_trgm) — typo tolerance
tsvector search does not do fuzzy/typo-tolerant matching by itself — a misspelled query term simply won't match. `pg_trgm` is Postgres's answer: it breaks strings into overlapping 3-character sequences (trigrams) and matches on trigram similarity, so "postgres serach" still overlaps enough trigrams with "postgres search" to be found.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm_idx
ON articles USING GIN (title gin_trgm_ops);
SELECT title, similarity(title, 'postgres serach') AS sim
FROM articles
WHERE title % 'postgres serach'
ORDER BY sim DESC
LIMIT 10;The % operator uses the default similarity threshold (0.3, tunable via SET pg_trgm.similarity_threshold) and the GIN index turns it into a bitmap index scan instead of a sequential scan; the same index also accelerates LIKE '%term%' and ILIKE queries. In practice, teams combine both: tsvector for ranked, stemmed full-document search, and pg_trgm on a title/name/SKU field for "did you mean" and autocomplete-style fuzzy lookups. Neither one gives you the token-level typo correction ("recieve" → "receive" as a word substitution) that Elasticsearch's fuzzy match query or Meilisearch's built-in typo tolerance handle natively.
Postgres full-text search vs BM25 — what Postgres lacks
ts_rank and BM25 solve the same problem — score a document against a query — with different math. BM25 is a probabilistic ranking function that accounts for term frequency saturation (a term appearing 20 times isn't 20x more relevant than once) and document-length normalization; ts_rank is a simpler weighted-frequency count that doesn't normalize for document length the same way and is generally considered a cruder relevance signal at scale.
Core Postgres does not ship BM25. If you want it inside Postgres rather than in a separate engine, ParadeDB's `pg_search` is the most active option: it adds a BM25-scoring index type built on Tantivy (a Rust, Lucene-like search library), plus faceting and aggregations, all queryable via SQL. It's a genuine extension, not core Postgres — it needs to be installed, some managed Postgres providers restrict or don't offer it (check your host's extension allowlist before committing to it), and it's a younger project than Lucene-based engines with a much smaller production track record. Treat it as "BM25 without a second datastore," not as a drop-in replacement for a mature search engine's operational maturity.
What you don't get from tsvector/GIN alone, and would need Elasticsearch/OpenSearch/Meilisearch/Typesense (or pg_search) for:
- BM25 relevance with tunable
k1/bparameters - Faceted search (counts per category/filter alongside results) as a first-class feature
- Typo tolerance at the token level, out of the box
- Horizontal scaling of the search workload independent of your primary database
- Search-specific ops tooling — relevance debugging, synonym management, learning-to-rank
Postgres full-text search vs Meilisearch (and Typesense)
Meilisearch and Typesense are purpose-built search servers, not general databases — they trade Postgres's transactional guarantees and SQL flexibility for search features that work out of the box. Meilisearch is MIT-licensed, ranks with its own relevance algorithm plus typo tolerance (by default one typo for words of five or more characters, two for words of nine or more, per Meilisearch's typo tolerance docs) built in, and added hybrid keyword+vector search in v1.6. Typesense is GPL-3.0-licensed, also typo-tolerant by default, and is built around instant, search-as-you-type latency.
Both require running a separate service and syncing your Postgres data into it — the same sync-pipeline tradeoff as Elasticsearch, just with a lighter-weight, easier-to-operate engine and a narrower feature set (neither aims to replace Elasticsearch's aggregation/analytics depth). They're a reasonable middle ground when you want out-of-the-box typo tolerance and fast instant-search UX without standing up a full Elasticsearch or OpenSearch cluster.
Text is normalized into lexemes at index time by `to_tsvector`; the same normalization happens to the query via `websearch_to_tsquery`, and `ts_rank` orders the matches.
Licensing, briefly
This matters if you're picking a self-hosted engine, not just a feature set. As of Elasticsearch's 2024 licensing change, Elastic ships Elasticsearch and Kibana source under a choice of SSPL, Elastic License v2, or the OSI-approved AGPLv3. OpenSearch, the AWS-led fork, remains Apache 2.0. Meilisearch's core is MIT. Typesense is GPL-3.0. Postgres itself, pg_trgm, and pg_search all use permissive-compatible licenses (PostgreSQL License / PostgreSQL License / AGPLv3 respectively — verify pg_search's license on its repo before adopting, since extension licensing can differ from core Postgres). None of this should be the deciding factor on its own, but it's worth checking against your organization's OSS policy before you build a dependency on any of them.
Decision table: when Postgres is enough
| Use case | Postgres FTS (tsvector + GIN) | Dedicated search engine |
|---|---|---|
| Docs/blog search, admin search box | Enough | Overkill |
| Product catalog, tens of thousands to low millions of rows | Usually enough | Only if you need faceting |
| E-commerce with faceted filters (price, brand, size counts) | Painful to hand-roll | Elasticsearch/OpenSearch/Typesense |
| Autocomplete / search-as-you-type at low latency | Workable with pg_trgm | Meilisearch/Typesense excel here |
| Typo-tolerant consumer search | Partial, via pg_trgm | Meilisearch/Typesense/Elasticsearch |
| Log/event analytics search at scale | Not designed for this | Elasticsearch/OpenSearch |
| Search traffic that would contend with OLTP writes on the same DB | Risk of resource contention | Separate cluster isolates load |
| Team with no capacity to run a second stateful service | Best fit | Adds ops burden |
If search sits inside a larger data pipeline — ingesting, cleaning, and serving structured content for analysis alongside search — that overlaps with general data analytics infrastructure work, and the same source-of-truth-vs-sync tradeoffs apply there too.
FAQ
Is Postgres full-text search good enough for production?
Yes, for small-to-mid-scale ranked search — a tsvector generated column with a GIN index and websearch_to_tsquery handles stemming, stop words, phrase queries, and relevance ranking without any extra infrastructure, and it's used in production by many applications that don't need faceting or BM25-grade relevance tuning.
Does Postgres full-text search support fuzzy matching or typos?
Not by default — tsvector matching requires the stemmed term to be present. Add the pg_trgm extension and a trigram GIN index on the relevant column to get similarity-based fuzzy matching for typos and near-matches.
What's the difference between GIN and GiST indexes for Postgres search?
GIN indexes are faster for the read-heavy, mostly-static case typical of full-text and trigram search (the default and recommended choice); GiST indexes build faster and support nearest-neighbor (<->) ordering, but lookups are generally slower than GIN for these operators — the Postgres documentation has the full tradeoff table.
When should I migrate from Postgres search to Elasticsearch?
Migrate when you need features tsvector genuinely can't do well — first-class faceted navigation, BM25-tuned relevance at scale, or search query volume high enough to risk contending with your transactional workload — not just because search feels "slow" before you've added a GIN index or checked your query plan.