Skip to content
A Bekkour
Writing

The Postgres features I reach for before I reach for a cache

5 min read

When a Rails endpoint gets slow, the reflex is to cache it. Sometimes that’s right. More often the query is slow because ActiveRecord is doing in Ruby something Postgres would do in one pass — loading a thousand rows to pick one per group, filtering an array in memory, running a search it should have indexed. On a multi-tenant SaaS I ran the backend for, the biggest wins came from deleting Ruby and letting the database earn its keep. Reworking the hot endpoints this way — right indexes, latest-per-group in SQL, cached reference data — took the worst ones down by around 40% at the median, before a single new cache entry.

Here are the three Postgres features that did most of the work, and where each one stops helping.

DISTINCT ON, for “the latest one per group”

“Show each record with its most recent status change” is the single most common shape of slow query I see. The ActiveRecord-idiomatic version is an N+1 — load the records, then per record load its latest event — or a correlated subquery that the planner handles badly at scale. Postgres has a purpose-built tool: DISTINCT ON.

# the latest stage change per record, in one query
StageChange
  .select("DISTINCT ON (stageable_id) *")
  .order("stageable_id, created_at DESC")

DISTINCT ON (stageable_id) keeps the first row for each stageable_id, and the ORDER BY decides which “first” means — here, most recent by created_at. One index scan, one row per group, no N+1, no subquery. It reads a little strange the first time (the DISTINCT ON column has to lead the ORDER BY), but once it’s in your vocabulary you see uses everywhere: latest price per item, most recent login per user, one row per client on a statement.

Where it stops: DISTINCT ON is Postgres-only, so it ties that query to Postgres. I’m fine with that trade — I’d rather use my database than pretend it’s interchangeable — but it’s a real coupling worth naming.

Array columns + overlap, instead of a join table you’ll regret

Some tags don’t deserve their own table. A party (a company or a contact) can be a client, a supplier, or both — a small, closed set of roles. Rather than a party_roles join table and the joins that come with it, these live in a Postgres array column, and membership is an overlap test with the && operator:

def filter_by_role(scope)
  case params[:role]
  when 'supplier'
    scope.where("type IN (?) AND ARRAY['supplier'] && subtypes",
                ['Crm::Parties::Company', 'Crm::Parties::Contact'])
  when 'client'
    scope.where("type IN (?) AND ARRAY['client'] && subtypes",
                ['Crm::Parties::Company', 'Crm::Parties::Contact'])
  end
end

ARRAY['supplier'] && subtypes is true when the two arrays share any element — “is this party a supplier.” Backed by a GIN index on the array column, it’s fast, and it keeps a whole join table and its bookkeeping out of the schema. This lives in a small query object (PartyQuery) that pipes the record set through filter_by_type, filter_by_subtype, filter_by_role — each a plain method that takes a scope and returns a scope, so the filters compose and stay testable.

Where it stops: arrays are the right call only for small, bounded tag sets you won’t need to attach data to. The day a “role” needs its own attributes (a start date, a permission), you want the join table, and migrating an array column back into one is annoying. Use this for genuinely simple membership, not as a reflex to avoid a table.

Full-text search you already have

Before adding a search service, check what Postgres gives you for free. Search here runs on a GIN index over a tsvector — Postgres’s own full-text search — so “find the customer named…” doesn’t fan out to another system for the common case:

# db/schema.rb
t.index "to_tsvector('simple', name)", name: "index_parties_on_fulltext_vector", using: :gin

A GIN tsvector index handles prefix and token search across a lot of rows without leaving the database, and it stays transactionally consistent with your data — no sync lag, no “the search index is behind again” incidents.

Where it stops: Postgres full-text is great until you need typo tolerance, relevance tuning, or faceting, at which point a dedicated search engine earns its complexity. The point isn’t that you’ll never need one — it’s that you should find out you need one, instead of reaching for it on reflex when a GIN index would have carried you for another two years.

Then, and only then, cache

Some things genuinely can’t be made cheaper per request — per-tenant reference data that’s read on nearly every page and changes a few times a week. That’s what caching is for, and in a schema-per-tenant app the discipline is that every key is namespaced by tenant, so invalidation can wipe exactly one customer’s cache and warmups can prime a tenant on demand:

def clear_tenant_cache(tenant_name = nil)
  tenant_name ||= Apartment::Tenant.current
  Rails.cache.delete_matched("tenant/#{tenant_name}/*")
end

One honest note, because it’s the kind of thing an interviewer probes: there are no counter caches in this codebase. Counts that matter are either indexed aggregates or cached deliberately — I didn’t sprinkle counter_cache: true around, because a counter cache is a small consistency liability you take on forever, and most of the counts here didn’t justify it. “We added counter caches” is an easy line to claim; I’d rather tell you the counts are handled by indexes and targeted caching.

The thread through all of this: caching is what you do when you’ve run out of ways to make the work cheaper, not the first move. Ask what the database could do that Ruby is currently doing badly, and a surprising amount of “we need a cache” turns out to be “we needed the right query.”


I spent years making a multi-tenant Postgres app fast — indexes, query shape, and knowing which problems the database should own. If yours is getting slow in the usual places, let’s talk.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing