Multi-Tenancy in Rails: Three Approaches and When Each One Breaks
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.
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 TouchRelated Articles
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