/ Tags: RAILS / Categories: RAILS

Multi-Tenancy in Rails — Row-Level vs Schema-Level, and the Cost of Each

Every B2B application eventually needs tenant isolation, and the decision gets made early — usually by whoever writes the first migration, usually without a discussion. Three months later you find out whether it was the right call, because changing it afterwards means migrating every table in the system. The choice comes down to two questions: how strong does isolation need to be, and how many tenants will you have?

The Three Models


Row-level — one database, one schema, a tenant_id column

Every tenant-scoped table carries an account_id. Queries filter on it. This is what most SaaS applications use and it should be your default.

Schema-level — one database, one PostgreSQL schema per tenant

Identical tables, duplicated per tenant, selected by setting search_path. Strong isolation without separate databases.

Database-level — one database per tenant

Maximum isolation. Separate connection per tenant, separate backups, separate everything.

  Row-level Schema-level Database-level
Isolation strength Application-enforced Database-enforced Total
Tenants supported Millions Hundreds Tens
Migration cost One migration One per schema One per database
Cross-tenant queries Trivial Painful Very painful
Connection pooling Simple Complex Complex
Per-tenant backup/restore Hard Moderate Trivial
Noisy-neighbour risk High Moderate Low

The row count where schema-level starts to hurt is lower than people expect. At five hundred tenants, a migration runs five hundred times and your deploy takes an hour. At two thousand, pg_dump becomes unusable and the system catalog itself gets slow.

Row-Level, Done Properly


The whole risk of row-level tenancy is one missing WHERE clause. Everything below is about making that impossible rather than unlikely.

Current tenant, request-scoped
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :account

  def account=(value)
    super
    Time.zone = value&.timezone || "UTC"
  end
end
class ApplicationController < ActionController::Base
  before_action :set_current_account

  private

  def set_current_account
    Current.account = Account.find_by!(subdomain: request.subdomain)
  rescue ActiveRecord::RecordNotFound
    render plain: "Unknown account", status: :not_found
  end
end

CurrentAttributes resets between requests, which is the property that makes it safe on a threaded server. A plain Thread.current[:account] does not, and a leaked tenant across requests on a Puma thread is the worst bug in this whole category.

Default scope, applied by a concern
module TenantScoped
  extend ActiveSupport::Concern

  included do
    belongs_to :account, default: -> { Current.account }

    default_scope do
      if Current.account
        where(account_id: Current.account.id)
      else
        raise TenantMissing, "#{name} accessed with no Current.account set"
      end
    end
  end

  class_methods do
    def unscoped_across_tenants
      unscope(where: :account_id)
    end
  end
end

default_scope has a bad reputation, and deservedly so for most uses — but tenant scoping is the case it was designed for. The important detail is the raise in the else branch: no tenant set means an exception, not an unscoped query. Failing closed is the entire point.

The default: -> { Current.account } on the association means new records get the tenant automatically, so Order.create!(...) in a controller can’t produce an orphan.

Enforce it in the database too

Application code is one layer. PostgreSQL Row-Level Security is another, and it catches the console session, the rake task, and the raw SQL your ORM didn’t generate.

Example:

class EnableRlsOnOrders < ActiveRecord::Migration[7.1]
  def up
    execute <<~SQL
      ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

      CREATE POLICY tenant_isolation ON orders
        USING (account_id = current_setting('app.current_account_id')::bigint);
    SQL
  end

  def down
    execute "DROP POLICY tenant_isolation ON orders; ALTER TABLE orders DISABLE ROW LEVEL SECURITY;"
  end
end
class ApplicationController < ActionController::Base
  around_action :with_tenant_session

  private

  def with_tenant_session
    ActiveRecord::Base.transaction do
      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(
          ["SELECT set_config('app.current_account_id', ?, true)", Current.account.id.to_s]
        )
      )
      yield
    end
  end
end

The true third argument to set_config makes it transaction-local, so it can’t leak to the next request on a pooled connection. Note this requires the application’s database role to not be the table owner or a superuser — RLS is bypassed for both.

RLS costs a small amount of query planning overhead and buys you a guarantee that no code path, including one written by someone who has never heard of your concern, can read another tenant’s rows.

Composite indexes lead with the tenant
add_index :orders, [:account_id, :status, :created_at]

Every index on a tenant-scoped table should start with account_id, because every query filters on it. An index on [:status] alone is nearly useless once the table holds a thousand tenants’ rows.

Schema-Level, and What It Costs


Example:

class TenantSwitcher
  def self.with(account)
    previous = ActiveRecord::Base.connection.schema_search_path
    ActiveRecord::Base.connection.schema_search_path = "\"#{account.schema_name}\", public"
    yield
  ensure
    ActiveRecord::Base.connection.schema_search_path = previous
    ActiveRecord::Base.connection.clear_query_cache
  end
end

That looks manageable. The problems are elsewhere:

  • Migrations. Every schema change runs once per tenant. Five hundred tenants means five hundred ALTER TABLE statements, each taking a lock, and a failure halfway leaves you in a mixed state.
  • Connection pooling. search_path is per-connection. Return a connection to the pool without resetting it and the next request runs against the wrong tenant. The ensure block above is load-bearing, and any code path that bypasses it is a data breach.
  • PgBouncer in transaction mode is effectively incompatible, since a connection can change between statements.
  • Schema cache. Rails caches column information per model, not per schema, so heterogeneous schemas mid-migration cause errors that are very hard to read.
  • pg_dump and monitoring degrade — the catalog gets large, and per-table stats become unusable.

Schema-level makes sense with a small number of high-value tenants who have contractual isolation requirements, and where you’re prepared to build tooling around migrations. It’s the wrong default.

Pro-Tip: Whichever model you choose, write the cross-tenant leak test on day one and run it in CI. Create two accounts, populate both with records, set Current.account to the first, and assert that every tenant-scoped model returns only its own rows — iterating over the models programmatically rather than listing them, so a new model added next year is covered automatically. ApplicationRecord.descendants.select { |m| m.include?(TenantScoped) }.each { |model| expect(model.count).to eq(expected_for_tenant_one) }. This single spec catches the missing include TenantScoped on a new model, the unscoped someone added to fix a bug, and the association that reaches across tenants through a has_many :through. It’s the highest-value test in a multi-tenant application and it takes twenty minutes to write.

The Escape Hatches, and Guarding Them


You will need cross-tenant queries — admin dashboards, aggregate reporting, support tooling. Make them explicit and auditable.

Example:

module CrossTenant
  def self.query(reason:, actor:)
    Rails.logger.warn("[cross-tenant] #{actor.email}: #{reason}")
    AuditEvent.create!(kind: :cross_tenant_query, actor:, metadata: { reason: })

    Current.set(account: nil) do
      yield
    end
  end
end
CrossTenant.query(reason: "monthly revenue rollup", actor: current_admin) do
  Order.unscoped_across_tenants.group(:account_id).sum(:total_cents)
end

One named entry point, logged and audited. Anything else that reaches across tenants is a bug you can find by grepping for unscoped.

Background Jobs


The most common place tenancy breaks, because Current is empty in a worker process.

Example:

class ApplicationJob < ActiveJob::Base
  around_perform do |job, block|
    account_id = job.arguments.first.try(:[], :account_id)
    raise TenantMissing, "#{job.class} enqueued without account_id" if account_id.nil?

    Current.set(account: Account.find(account_id)) { block.call }
  end
end

Pass the tenant explicitly in the job arguments — never rely on it being ambient — and fail loudly when it’s absent. The same applies to mailers, Action Cable channels, and anything running outside a controller.

Conclusion


Row-level tenancy with CurrentAttributes, a concern that fails closed when no tenant is set, tenant-leading composite indexes, and PostgreSQL RLS as a backstop covers the overwhelming majority of SaaS applications and scales to millions of tenants. Schema-level is a specialised tool for a small number of tenants with isolation requirements you can point at in a contract, and it costs you migrations, connection pooling, and observability. Whichever you pick, the leak test and an audited cross-tenant escape hatch are not optional — the difference between a well-run multi-tenant system and a breach is entirely in the guardrails, not in the model you chose.

FAQs


Q1: Is default_scope really acceptable here?
For tenancy, yes — it’s one of the few cases where the scope genuinely applies to every query and where forgetting it is a security bug rather than a display bug. The usual objection, that it’s hard to override, is a feature in this context.

Q2: What about acts_as_tenant or apartment?
acts_as_tenant implements roughly the row-level pattern described here and is a reasonable choice. apartment implements schema-level and is no longer actively maintained, which matters a great deal for something this load-bearing.

Q3: How do I handle tenant-agnostic models like Plan or Country?
Don’t include the tenant concern. Keep them in the public schema under schema-level tenancy, and simply omit account_id under row-level.

Q4: Does RLS hurt query performance?
The policy becomes an additional predicate in the query plan, and since you’re already filtering on account_id with an index leading on it, the marginal cost is small. Measure on your workload, but it’s rarely the bottleneck.

Q5: Can I move from row-level to schema-level later?
Technically yes, practically it’s a full data migration plus a rewrite of every tenant-aware code path. Going the other direction is worse. Choose deliberately at the start, and default to row-level unless you have a specific reason not to.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More