Elasticsearch Isn't Dead. You Probably Don't Need It.
Every team hits the same moment. Search gets slow, someone says "we need Elasticsearch," and two weeks later there's a new cluster, a sync pipeline, and a Slack channel called #search-is-down.
Here's the case for not doing that. PostgreSQL's built-in full-text search handles the kind of workload many teams actually have, and it does it without adding a second database to the architecture.
The real cost isn't the cluster, it's the copy
Adding Elasticsearch doesn't add a search feature. It adds a second brain that has to keep agreeing with your first one.
Your data lives in Postgres. Now it also has to live in Elasticsearch, duplicated, reshaped into documents, kept current through a queue or a change-data-capture pipeline or a reindex job someone wrote in a hurry two years ago. Every insert, update, and delete in Postgres needs a matching write on the other side. That sync layer is where the real cost lives, not in cluster fees:
- A customer updates their email. Postgres has the new one. Elasticsearch still returns the old one for six hours because the CDC consumer fell behind.
- A row gets deleted in Postgres. The reindex job crashed last Tuesday, so it still shows up in search, and support gets a ticket about a ghost customer.
- Someone runs a backfill migration, forgets the matching backfill in Elasticsearch, and search results quietly diverge from the database for a month before anyone notices.
None of these are Elasticsearch bugs. They're the tax you pay for keeping two representations of the same data that don't share a transaction boundary. Postgres full-text search skips this entirely, because there's nothing to sync. The searchable representation lives in the same database and is maintained transactionally with the data.
What Postgres actually gives you: tsvector and tsquery
Full-text search in Postgres is built on two pieces: tsvector, which turns text into a normalized, searchable format, and tsquery, which turns a search string into something you can match against it.
to_tsvector normalizes the text, removes stopwords according to the text-search configuration (here "make" survives, it isn't one), and applies stemming where the configured dictionary supports it. That's why a search for search can match text containing searching, the same behavior that makes Elasticsearch feel necessary in the first place.
The @@ operator matches a tsvector against a tsquery. That's full-text matching as a native Postgres operator, not a separate search service. It doesn't score relevance on its own, that's what ts_rank is for below.
For a real search box, to_tsquery is less useful than it looks, because it throws a syntax error on ordinary user input like an unbalanced quote or a trailing &. websearch_to_tsquery is the better default: it accepts web-search-style syntax (quoted phrases, -exclude, or) and never rejects plain text.
Reach for to_tsquery when you control the query syntax yourself. Reach for websearch_to_tsquery when the query comes from a search box.
The part that makes it production-ready: GIN indexes
If you compute the tsvector during every query, Postgres has to process the candidate rows to build those vectors on the fly, fine for a thousand rows, a real cost at ten million. For data that's searched regularly, storing the vector and indexing it with a GIN index (Generalized Inverted Index) avoids that repeated work.
That GENERATED ALWAYS AS ... STORED column is the detail that matters in production. Postgres computes it whenever the underlying row is inserted or updated, and the GIN index is maintained as part of the same database transaction, the same way any index is maintained when its underlying column changes. There's no separate consumer to fall behind and no second system that can temporarily disagree with the row.
A GIN index works like the index at the back of a textbook. Instead of scanning every page for the word "vacuum," you jump straight to the pages listed under V. Postgres does the same: instead of scanning every row's tsvector, it jumps straight to the rows containing your search terms.
Worth saying plainly: that index isn't free. It costs write overhead on every insert and update, and it costs disk, same as any index does. The difference isn't zero cost versus some cost, it's an index cost you were always going to pay somewhere versus that same index cost plus a whole second system to keep in sync. One cost, not two.
ts_rank scores matches by relevance, giving you the ranking step you'd otherwise reach for a search engine to provide. The query runs inside Postgres, alongside the rest of your relational data, without a network hop to a separate search cluster.
Filtering is where a bolted-on search engine shows its seams
Most real search features aren't just "find text," they're "find text, then filter by category, status, or whatever's live right now." If Elasticsearch is populated asynchronously from Postgres, that filter is only as current as the index. A row can go inactive in Postgres while the search index still considers it active, for however long the sync pipeline lags. In Postgres, the filter and the full-text predicate run against the same transactional dataset in the same WHERE clause. There's no separate search index that can lag behind the row, because the filter and the full-text predicate operate on the same Postgres data.
That single detail, an asynchronously maintained index lagging the source of truth, is usually the thing that quietly breaks in production long after the Elasticsearch launch party is over.
Where Elasticsearch still earns its keep
This isn't "Elasticsearch is bad." It's "most teams adopted it for a problem they didn't have."
Elasticsearch is worth reaching for when you need fuzzy matching and aggressive autocomplete beyond what pg_trgm comfortably covers, sophisticated relevance tuning, large-scale aggregations and faceting, distributed search across genuinely large datasets, log and observability workloads, or a search workload you deliberately want isolated from your transactional database. Any one of those is a real reason. None of them are "search felt slow once."
And Postgres full-text search isn't a drop-in replacement for all of that. If your product depends on typo tolerance, heavy autocomplete, deep linguistic analysis, or search-specific aggregations at real scale, the tradeoff changes quickly, and that's exactly when Elasticsearch's cost starts paying for itself.
Postgres does cover more of that ground than people expect, though, particularly the typo tolerance part. pg_trgm handles a different class of problem from full-text search: finding strings that are similar even when the spelling isn't exact, which is what powers a lot of "did you mean" and fuzzy-match behavior.
Full-text search matches words and lexemes. pg_trgm matches approximate string similarity. Elasticsearch earns its place when you need substantially more search infrastructure than either of those provides.
The actual decision
This was never really "Postgres vs. Elasticsearch." It's "don't add a second system until you've established that Postgres isn't enough."
Start with Postgres. Add full-text search. Add a GIN index. Add pg_trgm if you need fuzzy matching. Measure. Introduce Elasticsearch when the workload actually demands it, not when search first feels slow.
For many teams, that measurement never gets there. The search bar was never the hard part. Keeping two systems telling the same story was.