Introduction
A few years ago I worked on a Rails application that needed faster search.
Like many developers facing a search problem, I eventually reached for Elasticsearch. The results were immediate: search got faster, the feature shipped, and the project moved on.
At the time the conclusion seemed obvious. PostgreSQL wasn’t fast enough, Elasticsearch was. Case closed.
Looking back, I’m no longer sure that was the lesson.
Not because Elasticsearch didn’t work. It absolutely did. What I question today is whether Elasticsearch was actually solving the problem I thought I had.
The Conclusion Came Too Quickly
When a system gets faster right after you introduce a new technology, it’s tempting to hand all the credit to that technology.
That’s exactly what I did:
PostgreSQL search → slow → Elasticsearch → fast
The problem with that reasoning is that swapping a search engine is never a single change. Moving from “PostgreSQL through ActiveRecord” to “Elasticsearch through a JSON API” touches a lot of things at the same time: the query, how rows come back, how many Ruby objects get allocated, how much gets serialized, how garbage collection behaves.
When several variables move at once, attributing the win to one of them is a guess, not a measurement.
And I never measured.
Most of a Search Request Is Not Searching
Here is what a “search” actually did in that app:
SQL query → ~1000 rows → ActiveRecord objects → associations
→ Ruby allocations → GC → serialization → JSON
The interesting part is that only the first step is search. Everything after it is the Rails application doing the same work it does for any large collection: instantiating models, loading associations, allocating objects, and turning them into JSON.
The database can finish its query in a few milliseconds and the endpoint can still feel slow, because most of the time is spent after the query.
When Elasticsearch entered the picture, the search query changed — but so did all of that downstream work, and I never separated the two.
How I Would Measure It Today
These days I don’t trust “it got faster” as a diagnosis. I want to know what got faster. There are two cheap places to look.
The first is the database. EXPLAIN (ANALYZE, BUFFERS) tells you how long the
query really takes server-side:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || content)
@@ to_tsquery('english', 'postgres')
LIMIT 1000;
If the planner reports a few milliseconds of execution time, the database is not your bottleneck — no matter how slow the endpoint feels. On the project I keep describing, this is the part I never actually checked.
The second place is the Rails side, and it’s usually where the surprise is.
Instantiating a thousand ActiveRecord objects is far from free. A quick
Benchmark makes the gap obvious:
require "benchmark"
scope = Article.where("title ILIKE ?", "%postgres%").limit(1_000)
Benchmark.bmbm do |x|
x.report("to_a (full AR objects)") { scope.to_a }
x.report("pluck (id, title)") { scope.pluck(:id, :title) }
end
On a non-trivial model with a few associations, to_a is regularly an order
of magnitude slower than pluck. The SQL is identical. The difference is
everything Rails does with the rows.
That single benchmark would have reframed the whole project. If returning the same rows as plain tuples is ten times faster than returning them as models, then “search is slow” was at least partly “we materialize too much per request” — and that has nothing to do with the search engine.
For a request-level view, rack-mini-profiler (or stackprof /
memory_profiler for a sharper picture) shows the same story as a flamegraph:
the SQL bar is small, the allocation and serialization bars are not.
PostgreSQL Goes Further Than People Expect
The other thing that changed my mind is how much PostgreSQL can do before you genuinely outgrow it.
Most business apps don’t start with Google-scale search. They search users,
customers, projects, tickets, documents. For a lot of that, an indexed ILIKE
or PostgreSQL’s built-in full-text search is enough — and the key word is
indexed.
A full-text query without an index re-runs to_tsvector on every row. The fix
is a stored generated column plus a GIN index:
class AddSearchableToArticles < ActiveRecord::Migration[8.0]
def change
add_column :articles, :searchable, :virtual,
type: :tsvector,
as: "to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, ''))",
stored: true
add_index :articles, :searchable, using: :gin
end
end
In a Rails model, pg_search makes it pleasant to use — and, importantly,
points at the precomputed column instead of recomputing the vector on the fly:
class Article < ApplicationRecord
include PgSearch::Model
pg_search_scope :search,
against: [:title, :content],
using: {
tsearch: { tsvector_column: "searchable", prefix: true }
}
end
If you need fuzzy matching or typo tolerance, pg_trgm adds trigram
similarity, again backed by a GIN index. None of this replaces Elasticsearch.
It just covers a surprising amount of ground without adding a service. I wrote
about the details in Fast and Flexible: Unlocking PostgreSQL’s Full-Text
Search for Rails Apps.
The Hidden Cost of a New Service
When people compare PostgreSQL and Elasticsearch, the discussion usually stays on features. The more interesting axis is operational cost.
Adding Elasticsearch means adding another service to deploy, monitor, upgrade, and back up — plus another failure mode. And the one that bites later: a second datastore that has to stay in sync with your source of truth.
None of these are dealbreakers. They’re just costs, and they’re mostly invisible during development. They show up months later, in maintenance mode, when an index drifts out of sync or a cluster needs an upgrade during an incident.
So before adding a component, I want the benefit to be large enough to justify carrying that weight.
This Is Not an Anti-Elasticsearch Article
Elasticsearch is excellent software. For advanced relevance tuning, real typo tolerance, faceting, large-scale analytics, or semantic search, I’d reach for it again without hesitating.
It’s also worth being precise about where the speedup comes from. With a
typical Rails integration — searchkick or elasticsearch-rails — a search
returns IDs and the app reloads ActiveRecord records by default. If you do
that, you pay the same instantiation cost I described earlier; you only avoid
it when you deliberately read from the stored document instead of rehydrating
models. Which is exactly the kind of detail you can only get right once you’ve
measured where the time actually goes.
So this isn’t about avoiding Elasticsearch. It’s about avoiding premature complexity. The question isn’t “can Elasticsearch solve this?” — it almost always can. The question is “have I exhausted the tools I already run in production?” More often than not, the honest answer is no.
The Real Lesson
For years I thought the takeaway from that project was “Elasticsearch is faster than PostgreSQL.”
Today I think the takeaway is that I never identified the bottleneck. Maybe PostgreSQL was part of it. Maybe ActiveRecord instantiation was more expensive than I assumed. Maybe serialization dominated. Maybe Elasticsearch improved several stages at once. I genuinely don’t know — and that’s the point. I optimized something that worked without ever knowing what I had fixed.
These days, whenever search comes up, I ask two questions before evaluating any new technology:
- Have I identified the actual bottleneck, with numbers?
- Am I fully using the capabilities of the tools I already run?
PostgreSQL keeps surprising me here — not because it’s faster than Elasticsearch, but because it often solves problems teams assume require something more sophisticated. And if I do hit its limits, Elasticsearch will still be there. The difference is that next time I’ll be introducing it to solve a measured problem, not an assumed one.
That’s a lesson I wish I’d learned much earlier.
Share on
Twitter Facebook LinkedInHave comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht