Elasticsearch Isn't Dead. You Probably Don't Need It — Jatin Jain Saraf
Book a Slot<br>Elasticsearch Isn't Dead. You Probably Don't Need It<br>Aug 21, 2026<br>7 min read
JJS<br>Written by Jatin Jain Saraf · Senior Software Engineer
postgresqlelasticsearchfull-text-searchdatabase-architecturebackend<br>XFacebookLinkedInCopy📋
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.
sql
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.
sql
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.
sql
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.
sql
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...