If your app already runs on Postgres and you need "search that finds the right rows when someone types a few words," you almost certainly do not need Elasticsearch yet. Postgres has had built-in full-text search for over a decade — stemming, ranking, and a GIN index that keeps queries fast — and for most apps under a few million rows it is fast enough, accurate enough, and one fewer service to operate. Reach for a dedicated search engine when you hit its real limits, not by default.
I keep watching teams stand up an Elasticsearch or OpenSearch cluster for what is, in practice, a search box over ten thousand blog posts. Then they inherit an entire second data store to keep in sync, secure, back up, and pay for. Below is what Postgres actually gives you, how to wire it up, and the honest list of when it stops being enough.
What does Postgres full-text search actually do?
Postgres full-text search converts text into a tsvector — a sorted list of normalized lexemes (word roots) with their positions — and matches it against a tsquery. The normalization step is what makes it real search rather than LIKE '%word%': it lowercases, strips punctuation, removes stop words, and stems, so a search for running matches a document containing run, and mice can match mouse depending on the dictionary.
SELECT to_tsvector('english', 'The developers were running the migrations');
-- 'develop':2 'migrat':6 'run':4
Notice the and were are gone (stop words), and running/migrations are stemmed to run/migrat. A tsquery goes through the same pipeline, which is why matching works regardless of the exact word form the user typed.
The takeaway: this is linguistic matching, not substring matching — that difference alone is why LIKE is not "search."
How do I add it to an existing table?
The clean way as of Postgres 12+ is a generated column that stores the tsvector, plus a GIN index on it. You never have to remember to update it — Postgres recomputes it whenever the row changes.
ALTER TABLE articles
ADD COLUMN search_vector 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_vector);
setweight tags title matches as weight A and body matches as B, so you can rank title hits higher later. Now a query:
SELECT id, title,
ts_rank(search_vector, query) AS rank
FROM articles,
websearch_to_tsquery('english', 'postgres full text search') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
Two things worth knowing here. @@ is the match operator, and the GIN index makes it fast. And websearch_to_tsquery — added in Postgres 11 — parses input the way users expect from a search box: "quoted phrases", or, and -excluded terms all work, and it never throws a syntax error on messy input. Prefer it over to_tsquery (which is strict and will error on raw user text) and over plainto_tsquery (which ignores operators entirely).
The takeaway: a generated column plus a GIN index gives you self-maintaining, indexed search in two DDL statements.
How do I show highlighted snippets and rank sensibly?
Two functions cover the parts users actually notice. ts_headline returns a snippet with matched terms wrapped, and ts_rank_cd gives cover-density ranking that accounts for how close the terms appear.
SELECT id, title,
ts_headline('english', body, query,
'StartSel=<mark>, StopSel=</mark>, MaxWords=30') AS snippet,
ts_rank_cd(search_vector, query) AS rank
FROM articles,
websearch_to_tsquery('english', 'connection pooling') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;
One caveat that trips people up: ts_headline runs on the original text, not the indexed vector, so it re-parses the document at query time. On long documents over large result sets that gets expensive. Compute it only for the page of results you actually display (the LIMIT 10 above), never for the full match set.
The takeaway: rank with ts_rank_cd and highlight with ts_headline, but generate headlines only for the rows you're about to render.
What about typos and fuzzy matching?
This is the first real gap, and it's worth being blunt: stock Postgres full-text search does not do typo tolerance. Search kuberntes and you get nothing, because the misspelling stems to a different lexeme. Dedicated engines like Meilisearch and Typesense treat typo tolerance as a default feature; Postgres treats it as your problem.
The usual fix is the pg_trgm extension, which does trigram similarity and can match misspellings:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm ON articles USING GIN (title gin_trgm_ops);
SELECT title
FROM articles
WHERE title % 'kuberntes' -- % is the similarity operator
ORDER BY similarity(title, 'kuberntes') DESC
LIMIT 5;
In practice you end up running full-text search for the main query and falling back to (or blending in) trigram similarity for fuzzy matching. It works, but you're now maintaining two indexing strategies and tuning a similarity threshold by hand. That glue code is exactly the kind of thing a purpose-built engine ships out of the box.
The takeaway: if typo tolerance is a hard product requirement from day one, Postgres will make you build it yourself — factor that effort in honestly.
When is a dedicated search engine actually worth it?
Here's how I decide, based on where Postgres full-text search genuinely runs out of room:
| Need | Postgres FTS | Dedicated engine (ES / Meilisearch / Typesense) |
|---|---|---|
| Keyword search over structured/text columns | Strong | Strong |
| Operational simplicity | One database, already there | Separate service to run, secure, sync |
| Typo tolerance out of the box | No (needs pg_trgm glue) |
Yes |
| Faceted search / aggregations at scale | Workable but manual | Built-in, tuned for it |
| Sub-50ms search-as-you-type UX | Possible, gets harder past a few million rows | Designed for it |
| Relevance tuning knobs | Limited (weights, rank functions) | Extensive |
| Multi-language analyzers, synonyms | Basic dictionaries, manual synonyms | Rich, configurable |
| Scaling search independently of your DB | No — competes for the same resources | Yes |
The honest thresholds where I move off Postgres: when search-as-you-type latency matters and the corpus is past a few million rows; when the product needs typo tolerance, synonyms, and faceting as first-class features rather than side projects; or when search traffic is heavy enough that it starves your transactional workload sharing the same box. Note the split among the alternatives too — Elasticsearch/OpenSearch is the heavy, infinitely tunable option that also does log analytics; Meilisearch and Typesense are lighter, search-only engines that are far easier to run and shine at instant-search UIs. Don't reach for the heaviest tool if your real need is "fast, typo-tolerant search box."
The takeaway: migrate when typo tolerance, faceting at scale, or independent scaling become product requirements — not when the row count merely looks big.
A note on hybrid search and vectors
If you're here because of AI features, keep the two concerns separate. Full-text search answers "which documents contain these words," and vector search (via pgvector) answers "which documents mean something similar." They solve different problems, and the strong current pattern is hybrid search — run both, then combine the rankings. The relevant point for this post: you can do both inside Postgres, tsvector for keywords and pgvector for embeddings, before you commit to any external search infrastructure at all.
Bottom line
Start with Postgres full-text search if you're already on Postgres and your corpus is in the thousands-to-low-millions of rows — a generated tsvector column, a GIN index, and websearch_to_tsquery will cover the large majority of app search needs with zero new infrastructure. Add pg_trgm if you need fuzzy matching, and accept that you're doing integration work the dedicated engines give you for free. Move to Meilisearch or Typesense when instant-search UX and out-of-the-box typo tolerance are core to the product, and to Elasticsearch/OpenSearch when you also need large-scale faceting, analytics, or search that scales independently of your database. The mistake isn't choosing Elasticsearch — it's choosing it before you've felt a single limit of the search engine you already had running.
Top comments (1)
A useful production caveat is that “one fewer service” does not mean zero search-specific operations. The GIN index adds write amplification and can accumulate a pending list, so I’d load-test with the real insert/update rate and watch index size, pending-list cleanup, autovacuum, and p95 latency of the transactional workload—not just isolated search latency. Relevance also needs a contract: pin the text-search configuration per field/language, build a small judged query set, and track zero-result rate plus ranking metrics before changing dictionaries, weights, or trigram thresholds. Add a deterministic tie-breaker such as
ORDER BY rank DESC, idfor stable pagination. One security footnote:ts_headlinereturns fragments of the original document; if user-authored text can contain markup, escape/sanitize it before rendering and add the<mark>tags in a controlled layer. Those checks make the later “stay or migrate” decision evidence-based rather than row-count-based.