RUBY ON RAILS · 15 MIN READ ·

Rails Multi-Tenancy: Schemas, Row-Level, and Separate Databases — Choosing the Right SaaS Pattern

Rails multi-tenancy patterns: row-level scoping, Postgres schemas, separate databases. When each works, when it breaks, and how to migrate between them.

Rails Multi-Tenancy: Schemas, Row-Level, and Separate Databases — Choosing the Right SaaS Pattern

A B2B SaaS founder called me two summers ago, three years into building a product that had crossed two million in ARR and was now losing deals to enterprise buyers. The reason on every lost-deal note was the same: “isolation requirements.” His app had a single Postgres database, every row carried an account_id, and every controller did current_account.invoices.find(params[:id]). It worked, until a prospect’s procurement team handed them a 14-page security questionnaire that asked, in seven different ways, where their data physically lived. We spent the next four months migrating the top 8% of customers onto isolated databases while leaving the long tail on the shared cluster, and closed three enterprise contracts within ninety days of finishing.

After nineteen years of Rails I have built Rails multi-tenancy on every model I will describe here, and the lesson keeps repeating: the right pattern is not the one with the best isolation story, it is the one that matches your customer mix, your operational maturity, and the way your data actually grows. This post is the framework I walk through with every fractional CTO client trying to choose between row-level tenancy, Postgres schemas, and separate databases — including the migration paths between them, because most successful SaaS apps will eventually need more than one.

What Rails Multi-Tenancy Actually Means

A multi-tenant SaaS application serves many customers (tenants) from the same codebase. The question is where you draw the line between tenants in the database. There are three honest answers:

Row-level tenancy puts every tenant’s data in the same tables, with a tenant_id (or account_id, workspace_id, organization_id) column on every tenant-owned row. Every query filters by that column. This is what most Rails apps start with and what most stay on forever.

Schema-level tenancy uses Postgres schemas to give each tenant its own namespace inside one database. Tables are duplicated per schema; you switch schemas per request. This is what the now-archived apartment gem made famous in 2014.

Database-level tenancy gives each tenant a full separate database — same schema, separate physical storage. Connections are switched per request, usually based on subdomain or JWT claim.

None of these is universally correct. The best architectures I have built use two of them at once: row-level for the long tail, separate databases for the top customers, no schema-level anywhere. I will explain why.

Row-Level Rails Multi-Tenancy: The Default

Row-level Rails multi-tenancy is the right call for almost every SaaS that has not yet sold to a Fortune 500. It is operationally simple, scales cleanly until your largest tenant becomes a meaningful fraction of total data, and is the only model where you can run a single Rails console to investigate a bug across all customers.

The pattern is straightforward and has not changed much since 2010. Every tenant-owned model belongs to an Account. Every query is automatically scoped. Every controller sets a tenant context.

class Account < ApplicationRecord
  has_many :users
  has_many :invoices
  has_many :projects
end

class Invoice < ApplicationRecord
  belongs_to :account
  validates :account_id, presence: true
end

class ApplicationController < ActionController::Base
  before_action :set_current_account

  private

  def set_current_account
    Current.account = current_user.account
  end
end

class Current < ActiveSupport::CurrentAttributes
  attribute :account
end

The mistake most teams make is stopping there and trusting every controller to call current_account.invoices. That works for the first 50 controllers and breaks the moment one of your engineers writes Invoice.find(params[:id]) and ships it. We covered this exact failure mode in Rails Pundit authorization for multi-tenant SaaS — IDOR vulnerabilities are the number one bug class in row-level tenancy.

The fix is defense in depth. Use Pundit scopes for authorization, and add a Postgres-level safety net using Row Level Security (RLS) for the truly sensitive tables.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (account_id = current_setting('app.current_account_id')::bigint);
class ApplicationController < ActionController::Base
  around_action :set_tenant_context

  private

  def set_tenant_context
    ActiveRecord::Base.connection.execute(
      "SET LOCAL app.current_account_id = #{current_user.account_id.to_i}"
    )
    yield
  ensure
    ActiveRecord::Base.connection.execute("RESET app.current_account_id")
  end
end

Now even if a developer writes Invoice.find(params[:id]) and a hostile user passes another tenant’s ID, Postgres returns “not found” instead of leaking the row. This belt-and-braces approach is what I deploy when row-level tenancy is the chosen pattern but the data is sensitive — healthcare, financial, HR.

Row-level Rails multi-tenancy breaks down when a single customer’s data dominates your database. If one tenant has 80% of your events table rows, every query plan optimizes for them and starves everyone else. If one tenant has compliance requirements (data residency, encryption keys they control, audit log retention different from the rest), row-level cannot satisfy them at all. That is when you start looking at the next two options.

Schema-Level Tenancy: Why I No Longer Use It

Schema-level Rails multi-tenancy sounds appealing on paper. Each tenant gets a Postgres schema. SET search_path TO tenant_42, public and every query magically scopes itself. No account_id columns. No risk of cross-tenant queries. The apartment gem made this pattern popular for a few years.

In practice, schema-level tenancy is the worst of all three options for most production Rails apps, and I have not recommended it to a client since 2019. The reasons compound:

Migrations become a nightmare. A schema change has to run against every schema. With a thousand tenants, you are running the same migration a thousand times. Long-running schema changes block deploys. Adding a column to a 10-million-row table is hard enough once; doing it a thousand times per deploy is operationally hostile.

Connection pool pressure is brutal. Every request flips the search_path. PgBouncer in transaction mode does not like this, which I covered in Postgres connection pooling with PgBouncer for Rails. Either you pin connections (which destroys your pool sharing) or you risk leaking a search_path from one request into another.

Reporting across tenants becomes impossible. Want to know how many invoices exist across all customers? With row-level tenancy, that is a SELECT COUNT(*). With schema-level, you have to iterate through every schema, query each, and aggregate in Ruby. Every cross-tenant analytics query turns into a thousand-query loop.

Backups blow up. pg_dump of a database with a thousand schemas is dramatically slower than pg_dump of a database with one schema and a tenant_id column. RDS snapshots do not care, but logical replication does.

Schema-level tenancy makes sense in exactly one situation: you have a small number of large tenants (say, dozens, not thousands) and a regulatory requirement that says tenants must not share tables. That is rare. For everyone else, skip it.

Database-Level Tenancy: The Enterprise Tier

Separate databases per tenant — same Rails app, different physical Postgres clusters — is the right answer when one or more of these is true:

A specific customer has data residency requirements (their data must live in Frankfurt, not Virginia). A specific customer has encryption-at-rest requirements with a key they control. A specific customer is so large that they would dominate query plans on the shared cluster. You are selling to regulated industries where “logically isolated” is not enough.

Rails has supported this cleanly since Rails 6 with multiple databases, and Rails 7+ made it much easier with the multiple databases API. Here is the setup that works in production:

# config/database.yml
production:
  primary:
    database: saas_shared
    host: <%= ENV["DB_SHARED_HOST"] %>
    pool: 20
  primary_replica:
    database: saas_shared
    host: <%= ENV["DB_SHARED_REPLICA_HOST"] %>
    pool: 20
    replica: true

For the per-tenant databases, configure them dynamically rather than statically. You will not enjoy editing database.yml every time you onboard a new enterprise customer.

class TenantDatabaseSwitcher
  def self.with_tenant(account)
    config = {
      adapter: "postgresql",
      database: account.database_name,
      host: account.database_host,
      username: account.database_username,
      password: Rails.application.credentials.dig(:tenant_databases, account.id, :password),
      pool: 5
    }

    ActiveRecord::Base.establish_connection(config)
    yield
  ensure
    ActiveRecord::Base.establish_connection(:primary)
  end
end

class ApplicationController < ActionController::Base
  around_action :switch_tenant_database, if: :isolated_tenant?

  private

  def switch_tenant_database
    TenantDatabaseSwitcher.with_tenant(current_account) { yield }
  end

  def isolated_tenant?
    current_account&.isolated_database?
  end
end

This is the hybrid pattern I keep recommending: the long tail of customers lives on the shared cluster with row-level tenancy, and a small number of large or regulated customers gets a dedicated database. The Account model has an isolated_database? flag and a database_host column; the controller branches on it.

The operational cost is real. Each new tenant database needs provisioning, migrations, backups, monitoring, and connection management. You will end up writing a small internal tool that runs rails db:migrate against every tenant database on every deploy. Use GoodJob or SolidQueue to run those migrations in parallel, with one job per tenant database, and a status board so you can see which databases are on which schema version.

How to Decide Which Rails Multi-Tenancy Pattern to Use

After dozens of these conversations with founders, the decision tree is shorter than people expect:

Start with row-level. Every SaaS should start here unless you have a specific contractual reason not to. Row-level scales further than people fear — Basecamp, GitHub, Linear, and Notion all run massive row-level multi-tenant systems.

Add Row Level Security in Postgres if your data is sensitive (PII, PHI, financial). It is a one-evening change that adds a real safety net under your application-level scoping.

When your top 5% of customers start asking compliance questions you cannot answer with row-level — data residency, customer-managed encryption keys, dedicated backups — introduce database-level tenancy as a premium tier. Charge for it. The operational cost is real, and customers who need it understand they are paying for isolation.

Skip schema-level tenancy unless you have a specific regulatory requirement that says tables cannot be shared between tenants, and even then check whether RLS on the same tables would satisfy the auditor. Usually it does.

Migrating From Row-Level to Database-Level

This is the migration I have run most often, and the playbook is the same every time. You are not rewriting your app; you are moving one tenant’s rows from the shared database to a fresh isolated database, with zero downtime for that tenant.

The cleanest tool for this is Postgres logical replication, which I covered in detail in Postgres logical replication for Rails: zero-downtime major version upgrades. The same machinery works for splitting one tenant out of a shared database.

The sequence is:

  1. Provision a new empty database for the tenant. Run schema migrations so the structure matches.
  2. Create a publication on the shared database that filters to only this tenant’s rows. Postgres 15+ supports row filters in publications, which is exactly what you need.
CREATE PUBLICATION tenant_42_export
  FOR TABLE invoices WHERE (account_id = 42),
      TABLE projects WHERE (account_id = 42),
      TABLE users WHERE (account_id = 42);
  1. Create a subscription on the new database. Initial copy plus streaming changes. Wait for pg_stat_subscription.received_lsn to catch up.
  2. During a brief maintenance window for that tenant only, flip the Account.isolated_database? flag and the database_host column. The next request from that tenant routes to the new database.
  3. Verify the new database is receiving writes. Stop the subscription. Drop the publication. Delete that tenant’s rows from the shared database.

Total user-visible downtime per tenant: about thirty seconds, contained to that tenant’s requests. The rest of the SaaS keeps running. We did this for fourteen enterprise customers over two months without a single complaint and without a single production incident.

FAQ

Should I use the apartment gem for Rails multi-tenancy?

The apartment gem has not had a meaningful release since 2020 and is functionally archived. I do not recommend it for new projects. The schema-level pattern it implements has the operational problems described above, and the few teams I know running it in production are actively migrating off. If you want the row-level pattern, write it yourself with Current.account and Pundit scopes — it is fewer than 100 lines of code and you understand every line. If you want isolation, use separate databases.

How does Rails multi-tenancy work with background jobs?

The job needs to know which tenant it is running for. Pass account_id as a job argument, and in a base job class set Current.account before perform runs and reset it after. If you use database-level tenancy, your job also needs to switch the connection. Wrap the connection switch around perform. SolidQueue and GoodJob both make this clean because the job runs in its own thread; never set tenant context outside the perform boundary, or you will leak state between jobs.

Is Postgres Row Level Security a replacement for application-level scoping in Rails?

No, it is a safety net under it. Application-level scoping (Pundit, current_account.invoices) is your primary defense and is where you write the rules. RLS catches the case where a developer forgets to scope a query. Run both. RLS without application scoping makes every query slightly slower and gives you no good error messages; application scoping without RLS gives you a single bug away from a tenant data leak.

When does Rails multi-tenancy stop scaling?

Row-level tenancy on Postgres scales to billions of rows and thousands of tenants without trouble if you index tenant_id properly and partition your largest tables. The point at which row-level breaks is not “we hit some row count” — it is “one of our tenants now has compliance requirements the shared cluster cannot meet.” Plan for the hybrid pattern as a future-state, but do not pre-build it. Migrate when a real customer asks.

Need help designing or migrating a multi-tenant Rails architecture for your SaaS? TTB Software specializes in Rails architecture, database design, and the operational maturity needed to support enterprise customers. We’ve been doing this for nineteen years.

#rails-multi-tenancy #multi-tenant-saas-rails #postgres-schemas-multi-tenant #rails-row-level-tenancy #saas-database-architecture #apartment-gem-alternative #ruby-on-rails

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software