Every Rails founder I talk to eventually asks some version of the same question: “How do I keep one customer’s data from leaking into another’s?” Sometimes it’s phrased as a security concern, sometimes as a billing one, sometimes as “what does Stripe do.” The honest answer is that “multi-tenancy” is three different patterns wearing the same name, and picking the wrong one costs you either weeks of work you didn’t budget for or a 2am incident you will not enjoy. This post is the rubric I wish someone had handed me the first time I had to choose: row-scoped, schema-per-tenant, or database-per-tenant — what each one actually means in a Rails codebase, where each one breaks, and how to decide which one your app needs before you write the migration that locks the answer in.
What people mean when they say “multi-tenancy”
Multi-tenancy in Rails is a question about isolation: how do you keep customer A’s rows from being visible to customer B, and how strong does that fence have to be. The three patterns differ in where the fence lives.
Row-scoping puts the fence inside your application code. Every table that holds tenant data gets an organization_id (or account_id, or tenant_id) column, and every query is filtered by it. The database is one shared schema. Schema-per-tenant moves the fence into Postgres itself: each tenant gets its own schema with its own copy of every table. Database-per-tenant goes further — each tenant gets its own database, often its own connection pool, sometimes its own server.
The fence’s location determines what a bug costs you. A missing where(organization_id:) in row-scoping is a data leak. A missing schema-switch in schema-per-tenant raises an exception. A missing connection-switch in database-per-tenant fails to connect at all. The patterns trade developer ergonomics for blast-radius reduction, and that trade is the whole game.
The rubric
The pattern you want depends on four properties of your app. I think about them in this order.
| Question | Row-scoping | Schema-per-tenant | Database-per-tenant |
|---|---|---|---|
| Number of tenants | 1 → millions | 1 → ~thousands | 1 → ~hundreds |
| Cross-tenant queries (analytics, admin) | Trivial | Painful | Very painful |
| Per-tenant schema drift | Impossible | Possible | Possible |
| Compliance / data-residency boundary | Weak | Medium | Strong |
| Migration runtime | One pass | One pass per tenant | One pass per tenant |
| Connection pool pressure | Low | Medium | High |
| ”I leaked customer data” failure mode | Subtle | Loud | Loud |
The defaults shake out like this:
- Default to row-scoping for B2B SaaS where customers are small-to-medium teams, you do cross-tenant analytics, and a typical tenant has thousands of rows rather than billions.
- Reach for schema-per-tenant when individual tenants have a lot of rows (tens of millions) and queries start hitting indexes painfully, or when one customer wants a custom column you can’t justify adding to everyone.
- Reach for database-per-tenant when a single tenant is paying enough that they get their own SLA, when data-residency contracts force per-tenant geographic placement, or when a tenant is large enough to justify their own backup/restore cadence.
Most Rails SaaS apps that ask me this question want row-scoping and don’t know it yet. The companies that genuinely need stronger isolation usually know why — a regulator told them, or a single enterprise customer’s procurement form said so.
The pitfalls
The row-scoping pitfall is the one everyone has heard about and most people still hit: the missing where clause. User.find(params[:id]) looks innocent until you realise params[:id] came from a URL the attacker controls, and your app just returned someone else’s user. The fix is a current_organization.users.find(params[:id]) discipline, enforced by either ActiveRecord scopes, a gem like acts_as_tenant, or — better — Postgres row-level security policies that fail closed if the app forgets.
The schema-per-tenant pitfall is migration drift. You run rake db:migrate and it touches schema A. Customer B’s schema, created last week from a snapshot, is missing the new column. You discover this at 11pm when their dashboard 500s. The fix is to treat schemas as a population — every migration runs against every schema, in a job, with monitoring, and a pg_dump --schema-only diff to verify they match.
The database-per-tenant pitfall is connection-pool pressure. Postgres connections are not free. A hundred tenants × thirty Puma workers × one connection each = three thousand idle connections, which is more than most Postgres instances allow. PgBouncer in transaction mode helps. So does accepting that this pattern is for small numbers of large tenants, not large numbers of small ones.
How the row-scoped pattern actually looks
Here is the shape I reach for first. It is not novel — variations of it have been in Rails apps for fifteen years — but the boring shape is the point.
A current_organization is set per-request, usually from the subdomain or a session value:
class ApplicationController < ActionController::Base
before_action :set_current_organization
private
def set_current_organization
Current.organization = Organization.find_by!(subdomain: request.subdomain)
end
end
Every tenant-owned model belongs to an organization and exposes a default scope that’s enforced at the database layer via row-level security:
class Project < ApplicationRecord
belongs_to :organization
end
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (organization_id = current_setting('app.current_organization_id')::bigint);
A connection-level setter wires the two together:
class ApplicationRecord < ActiveRecord::Base
def self.with_tenant(organization)
connection.execute("SET app.current_organization_id = #{organization.id.to_i}")
yield
ensure
connection.execute("RESET app.current_organization_id")
end
end
The discipline is: app code still writes current_organization.projects.find(...) for ergonomics, but if a developer ever writes a bare Project.find(...), Postgres returns zero rows for the wrong tenant. The fence has two layers, and the inner one is enforced by a database that does not forget.
You can build the same thing without RLS — acts_as_tenant will set a default scope in Ruby — but I prefer the database fence because the failure mode is “no rows” rather than “wrong rows”, and because Postgres enforces it for the rails console too, where I am most likely to do something stupid.
What done looks like
You know your multi-tenancy is working when four things are true.
First, every tenant-owned table has either an explicit organization_id column or lives in a schema/database that is itself tenant-scoped. There is no ambiguous middle ground where some rows belong to “everyone” — those rows live in genuinely-shared tables (countries, currencies, plan tiers) and are marked as such in your schema documentation.
Second, you can articulate the blast radius of a forgotten scope in one sentence. For row-scoping with RLS, it’s “the query returns zero rows.” For row-scoping without RLS, it’s “the query returns another tenant’s rows.” Knowing the answer lets you decide whether to add the RLS layer.
Third, every background job, every Sidekiq retry, every scheduled task either sets the tenant explicitly at the start or is provably tenant-agnostic. The audit is mechanical: grep for perform, check each one.
The fence is only as strong as the loneliest job that forgot to mention it.
— Self note, after one such job
Fourth, you have an answer for the admin/analytics question. Either there’s a “platform admin” role that bypasses scoping in a single explicit spot, or you maintain a separate analytics replica with its own credentials. “I’ll figure it out later” is fine on day one and fatal by month six.
When none of this applies
Skip this rubric entirely if you are building a single-tenant app — an internal tool, a one-customer consulting build, a personal project. Adding organization_id to every table because it might be useful “someday” is the canonical premature abstraction. You can always retrofit row-scoping later; it is annoying but not hard, and the conversion forces you to discover the actual tenancy model rather than guess it.
Also skip it if your “tenants” are really just users with their own data — a habit tracker, a notes app, a personal-finance tool. belongs_to :user is sufficient. Calling that “multi-tenancy” makes the code sound more impressive and the conversations more confusing.
The falsifiable claim
If your SaaS has fewer than a thousand tenants, no signed data-residency contracts, and you do any cross-tenant reporting, row-scoping with Postgres RLS will be the right answer and you will not regret it for at least three years. The pattern is boring, the migrations are one-pass, and the failure mode is “no rows” instead of “wrong rows.” The people who tell you otherwise are usually selling a multi-tenancy gem or reasoning from a problem they had at a company ten times your size.