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 TABLEstatements, each taking a lock, and a failure halfway leaves you in a mixed state. - Connection pooling.
search_pathis per-connection. Return a connection to the pool without resetting it and the next request runs against the wrong tenant. Theensureblock 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_dumpand 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.accountto 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 missinginclude TenantScopedon a new model, theunscopedsomeone added to fix a bug, and the association that reaches across tenants through ahas_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.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀