Skip to content
A Bekkour
Writing

Schema-per-tenant Rails, and the bug that only shows up in a background job

5 min read

Most multi-tenancy tutorials show you the same thing: add a tenant_id column, slap a default_scope on your models, done. That works right up until a background job runs with no tenant set and quietly reads — or writes — the wrong company’s data. On a field-service SaaS I ran the backend for, we sidestepped the whole tenant_id-on-every-table approach and gave each tenant its own Postgres schema. It made the day-to-day code blissfully tenant-unaware. It also moved all the danger to one place: the boundary where a job leaves the web request.

This is about that boundary.

Each tenant is a schema, and almost nothing knows

We used the apartment gem for schema-based isolation. The important config is short: only a handful of tables live in the shared public schema — the ones that have to be global, like the tenant registry and the users-to-tenants mapping — and everything else lives per-tenant.

# config/initializers/apartment.rb
config.excluded_models = %w{ Tenant TenantUser User FeatureFlag FeatureFlagTenant FeatureFlagAudit }
Rails.application.config.middleware.use Apartment::Elevators::Tenant

The payoff of schema isolation is that your domain models carry no tenant column and no tenant scope. A query for invoices is just Invoice.where(...) — it can’t accidentally return another tenant’s rows because the other tenant’s rows aren’t in the schema the connection is pointed at. That’s a strong guarantee, and it’s structural, not something a developer has to remember on every query.

Switching schema per request is a Rack “elevator” keyed on the subdomain:

def parse_tenant_name(request)
  request_subdomain = subdomain(request.host)
  tenant = excluded_subdomains.include?(request_subdomain) ? nil : request_subdomain

  if tenant
    ::Tenant.current_tenant = ::Tenant.where("LOWER(subdomain) = LOWER(?)", tenant).first
    ::Tenant.current_tenant.name
  else
    ::Tenant.current_tenant = nil
  end
end

Switching isn’t only SET search_path. The tenant also decides the timezone and the locale, so the switch has to re-establish those too — otherwise a job formats a date or a currency in the wrong region:

def self.switch_to_block(name, &block)
  Apartment::Tenant.switch(name) do
    Tenant.current_tenant = Tenant.where(name: name).first
    Time.zone = Preferences.timezone
    I18n.locale = resolved_locale   # validated against I18n.available_locales, with a fallback
    yield block
  end
end

Tenant lifecycle falls out of the same idea: creating a tenant creates a schema (after_create :create_schemaApartment::Tenant.create), destroying one drops it. Onboarding a customer is a DDL operation, which is a genuinely different mental model from inserting a row — with trade-offs I’ll come back to.

Where it breaks: the queue has no subdomain

Here’s the whole problem in one sentence: the Rack elevator sets the tenant from the request’s subdomain, and a background job has no request. Enqueue an invoice-sync job while serving acme.example.com, let it run on a worker a second later, and there is no subdomain to read. The connection is pointed at public (or worse, whatever schema the worker touched last), and your “just Invoice.where(...)” guarantee evaporates. Depending on timing you get no rows or, if a previous job left a schema selected on that thread, someone else’s rows.

We ran jobs on Amazon SQS via Shoryuken, and the fix is a pair of middlewares — one on the way out, one on the way in. On enqueue, stamp the current tenant onto the SQS message as an attribute:

class ShoryukenClientApartment
  def call(body)
    if body[:message_attributes]
      body[:message_attributes][:tenant] = { string_value: Apartment::Tenant.current, data_type: "String" }
    end
    yield
  end
end

On the worker, read that attribute back and run the whole job inside the tenant’s schema:

class ShoryukenServerApartment
  def call(worker_instance, queue, sqs_msg, body)
    tenant_name = sqs_msg.message_attributes["tenant"]
    Tenant.switch_to_block(tenant_name ? tenant_name.string_value : "public") do
      yield
    end
  rescue => e
    Rollbar.error e
    raise e
  end
end

That’s the entire fix, and it’s worth staring at because it’s the pattern for carrying any request-scoped context across a queue — tenant, locale, a request id, the acting user. The queue is a serialization boundary; anything implicit on the sender that the job needs has to be made explicit on the message. The tenant was implicit (it lived in Apartment::Tenant.current, derived from the subdomain), so it had to be written onto the envelope and re-applied on the other side. Wrapping the whole yield in switch_to_block — not just part of it — matters: every query, every nested job the worker enqueues, every callback, runs inside the right schema, and it’s restored when the block exits so the next job on that worker starts clean.

Note the "public" fallback. If a message somehow arrives without a tenant, we run against the shared schema, which has none of the domain tables — so the job fails loudly instead of silently operating on the wrong data. A job that can’t find its tenant should crash, not guess.

Was schema-per-tenant worth it?

Honestly, it depends, and I wouldn’t reach for it by default. What you buy is real: isolation you can’t forget to apply, trivially per-tenant everything (backups, exports, “restore just this customer”), and no WHERE tenant_id = ? noise in a thousand queries. What you pay is also real: migrations run once per schema, so a thousand tenants is a thousand ALTER TABLEs (and a slow, carefully-batched deploy); connection pooling and search_path switching add operational edges; and onboarding is DDL, which is heavier than an INSERT. Row-level tenancy inverts every one of those trade-offs.

But the lesson that transfers regardless of which model you pick is the queue one. Whatever holds your tenant context in a request — a thread-local, a CurrentAttributes, a connection’s search_path — is invisible to a background job, and “invisible” is exactly how a cross-tenant data leak gets written. Put the tenant on the message. Re-apply it around the entire job. Make the missing-tenant case fail instead of default.


I owned this multi-tenant backend for years — schema isolation, the job plumbing, the tenant lifecycle. If you’re weighing schema-per-tenant vs row-level and want to talk through the trade-offs for your app, my inbox is open.

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