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.

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht