35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Multi-Tenancy in Rails: Three Approaches and When Each One Breaks

Multi-Tenancy in Rails: Three Approaches and When Each One Breaks

TTB Software
rails, architecture
Scoped queries, schema-per-tenant, or database-per-tenant? A practical comparison of Rails multi-tenancy approaches with real-world breaking points.

You’re building a SaaS product. Multiple customers share your application. Sooner or later, someone asks the question that has derailed more architecture meetings than tabs-versus-spaces: how do we keep their data separate?

Rails gives you enough rope to implement multi-tenancy in several ways. The challenge isn’t picking one — it’s understanding where each approach starts creaking under load, and how painful the migration becomes when you need to switch.

Approach 1: Scoped Queries with a tenant_id Column

The most common starting point. Every table gets a tenant_id column, and every query gets scoped.

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  belongs_to :tenant

  default_scope { where(tenant_id: Current.tenant_id) }
end

Plenty of teams reach for default_scope here. It works until it doesn’t. The problem with default_scope is that it’s invisible — six months later, a developer writes a report query and forgets they’re looking at one tenant’s data. Or worse, they unscoped their way into a data leak.

A more explicit pattern uses ActsAsTenant or a manual concern:

module Tenanted
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant
    validates :tenant_id, presence: true

    scope :for_tenant, ->(tenant) { where(tenant: tenant) }
  end
end

Then in your controller layer:

class ApplicationController < ActionController::Base
  before_action :set_current_tenant

  private

  def set_current_tenant
    Current.tenant = Tenant.find_by!(
      subdomain: request.subdomain
    )
  end
end

Where it breaks: Around 50-100 tenants with wildly different data volumes. Your largest customer has 40 million rows in orders. Your smallest has 200. The same indexes serve both, but the query planner makes very different decisions at those scales. You end up writing tenant-specific query optimizations, which defeats the simplicity that drew you here.

The real pain point is migrations. Applying zero-downtime migration techniques helps, but adding an index on a 200-million-row table blocks writes. Your small tenants don’t need the index at all. You’re optimizing for everyone and nobody simultaneously.

Approach 2: Schema-per-Tenant (PostgreSQL Schemas)

PostgreSQL schemas let you namespace tables within a single database. Each tenant gets their own schema with identical table structures. The apartment gem popularized this in Rails:

# config/initializers/apartment.rb
Apartment.configure do |config|
  config.excluded_models = %w[Tenant]
  config.tenant_names = -> { Tenant.pluck(:subdomain) }
end

Switching tenants becomes a schema switch:

Apartment::Tenant.switch!('acme_corp')
# All queries now hit the acme_corp schema

This is genuinely elegant for small-to-medium tenant counts. Each tenant’s data is physically separate, so there’s no risk of cross-tenant leakage from a missing WHERE clause. Backups and data exports per tenant become straightforward — dump one schema.

Where it breaks: Migrations. Every rails db:migrate runs against every schema sequentially. With 500 tenants, a simple add_column takes minutes. With 5,000 tenants, you’re looking at hours. The apartment gem handles this, but the operational reality is painful.

Connection pooling gets complicated too. PostgreSQL’s search_path is connection-level state. If you’re using PgBouncer in transaction mode (and you should be at scale), schema switching per-request conflicts with connection sharing. You end up needing session mode for tenant-switching connections, which limits your pool efficiency.

There’s also the ceiling on total objects per database. PostgreSQL handles thousands of schemas fine, but monitoring, vacuuming, and pg_stat_user_tables all slow down as your catalog grows.

Approach 3: Database-per-Tenant

Each tenant gets their own database. Complete isolation. In Rails 6+, you can use the built-in multi-database support:

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    default: { writing: :primary, reading: :primary_replica },
    tenant_a: { writing: :tenant_a, reading: :tenant_a_replica },
    tenant_b: { writing: :tenant_b, reading: :tenant_b_replica }
  }
end

Or dynamically, which is more practical:

module TenantConnection
  def self.switch(tenant)
    config = tenant.database_config
    ActiveRecord::Base.establish_connection(config)
    yield
  ensure
    ActiveRecord::Base.establish_connection(:primary)
  end
end

The isolation is real. One tenant’s runaway query can’t starve others. You can put high-value tenants on dedicated hardware. Compliance becomes simpler — “your data lives in this specific database in this specific region.”

Where it breaks: Operational complexity scales linearly with tenant count. Each database needs its own backup schedule, its own monitoring, its own connection pool. Migrations require orchestration — you need tooling to run migrations across hundreds of databases, handle failures partway through, and track which databases are on which schema version.

Cross-tenant reporting is also miserable. “How many total users do we have?” requires querying every database. Build a separate analytics pipeline early, or you’ll regret it.

The Decision Framework

Here’s how I advise teams as a fractional CTO:

Start with scoped queries if you have fewer than 100 tenants with roughly similar data volumes, your team is small, and you need to ship fast. The acts_as_tenant gem adds guardrails without the complexity of schema management.

Move to schema-per-tenant if you need stronger isolation guarantees, your tenant count stays under a few thousand, and you’re on PostgreSQL. The migration overhead is manageable with tooling like apartment-sidekiq for parallel migrations.

Go database-per-tenant if you have compliance requirements (data residency, SOC 2 with tenant isolation), tenants with dramatically different performance profiles, or enterprise customers who require it contractually.

The Hybrid Nobody Talks About

Most mature SaaS platforms end up hybrid. Small tenants share a database with scoped queries. Mid-tier tenants get dedicated schemas. Enterprise tenants get dedicated databases. The routing layer decides based on tenant tier:

class TenantRouter
  def self.connect(tenant)
    case tenant.tier
    when 'enterprise'
      establish_dedicated_connection(tenant)
    when 'professional'
      switch_schema(tenant)
    else
      set_tenant_scope(tenant)
    end
  end
end

This is more code to maintain, but it matches the economic reality: you can’t afford dedicated infrastructure for free-tier tenants, and you can’t put your largest customer on shared tables with a WHERE clause as their only protection.

Migration Between Approaches

The migration path matters more than the starting point. Moving from scoped queries to schemas is relatively painless — create schemas, copy data, update the routing. Moving from schemas to separate databases is harder but doable.

Moving in the opposite direction — consolidating separate databases into shared tables — is a nightmare. You’re merging data, resolving ID conflicts, and rewriting queries. I’ve done it once. I wouldn’t recommend it.

Pick the simplest approach that handles your next 18 months. Build the abstraction layer now so the tenant resolution logic lives in one place. When the time comes to migrate, you’ll change the router, not every query in your application.

The teams that get multi-tenancy right aren’t the ones who picked the perfect architecture on day one. They’re the ones who made the switch straightforward when the requirements changed. If you’re unsure which approach fits your stage, a fractional CTO can help you make that call based on your actual data and growth trajectory.

Frequently Asked Questions

Which multi-tenancy approach is best for a new Rails SaaS?

Start with scoped queries using the acts_as_tenant gem. It’s the simplest to implement, requires no special database configuration, and works well for most apps until you hit 50-100 tenants with very different data volumes. You can always migrate to schema or database isolation later if needed.

How do I prevent data leaks between tenants?

Use the acts_as_tenant gem or a custom concern that enforces tenant scoping at the model level — never rely on manual WHERE clauses in every query. Write integration tests that create data for two tenants and verify that queries for one tenant never return the other’s data. Schema-per-tenant and database-per-tenant provide stronger isolation by design.

Can I mix multi-tenancy approaches in the same application?

Yes, and most mature SaaS platforms do exactly this. Small tenants share tables with scoped queries, mid-tier tenants get dedicated schemas, and enterprise tenants get isolated databases. The key is building a tenant router abstraction early so the switching logic lives in one place.

How does multi-tenancy affect Rails caching?

Every cache key must include the tenant identifier, or you’ll serve one tenant’s cached data to another. With scoped queries, include Current.tenant_id in your cache keys. With schema-per-tenant, the schema switch handles it implicitly. Always test cache isolation explicitly in your test suite.

T

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation