Multitenancy is an architecture where one application serves many customers, called tenants, and each tenant sees only its own data. A tenant is usually a company, team or account, not a single user: several people log in to the same tenant and share its records. The code, the servers and often the database are shared. The data is not.
The design question is where the wall between tenants lives: in your queries, in the database's namespaces, or in separate databases.
Three ways to build it in Rails
Row-level: a tenant column on every table. Every tenant-owned table gets a column such as account_id, and every query filters on it.
- Cheapest to run. One migration path, one connection pool, normal indexes.
- Cross-tenant reporting is a plain query.
- The wall is in application code. Forget the filter once and a tenant sees someone else's rows.
Schema per tenant. On PostgreSQL, each tenant gets its own schema, a namespace holding a full copy of your tables. The app switches the connection's search path per request.
- A missing
wherecannot cross tenants, because the other tenant's tables are not in the search path. - Every migration runs once per tenant, so deploys get slower as tenants grow.
- Cross-tenant queries need you to name each schema.
Database per tenant. Each tenant gets a separate database, sometimes on a separate server.
- The strongest isolation. You can back up, restore, move or delete one tenant on its own.
- The most to operate: connections, migrations and monitoring multiply by the tenant count.
- It suits a few large customers with contractual isolation needs, not thousands of small ones.
Apartment's own README recommends row-level tenancy for greenfield projects and apps with many tenants. Schema and database per tenant earn their cost when a customer contract or regulator asks for physical separation.
Row-level with acts_as_tenant
acts_as_tenant is the common gem for row-level tenancy. Its latest release is 2.2.2, published in October 2026 (checked in November 2026).
# Gemfile
gem "acts_as_tenant"
Add the tenant column, then declare it on the model. acts_as_tenant :account adds the belongs_to :account for you, so do not declare both.
# bin/rails g migration AddAccountToProjects account:references
class Project < ApplicationRecord
acts_as_tenant :account
validates_uniqueness_to_tenant :name
end
Set the current tenant once per request. The gem ships helpers for subdomains, or you set it yourself from the signed-in user.
class ApplicationController < ActionController::Base
set_current_tenant_through_filter
before_action :set_tenant
private
def set_tenant
set_current_tenant(current_user.account)
end
end
From then on Project.all returns only the current account's projects, and Project.new fills in account_id. The scoping comes from a default_scope, so it applies to every query built from the model, including update_all and destroy_all. validates_uniqueness_to_tenant lets two tenants each have a project called "Website". The models stay ordinary Active Record classes.
Schema per tenant with Apartment
For schema per tenant, the usual gem is Apartment. Its README describes the ros-apartment gem as a maintained fork of the original. The stable release is 3.4.4, and a 4.0 line is in alpha. The core API is a block that switches tenant:
Apartment::Tenant.create("acme")
Apartment::Tenant.switch("acme") do
Project.create!(name: "Website") # written to the acme schema
end
What Rails itself offers
The multiple databases guide covers horizontal sharding, which it describes as commonly called multi-tenant sharding. You declare shards with connects_to and switch with connected_to:
class ShardRecord < ApplicationRecord
self.abstract_class = true
connects_to shards: {
shard_one: { writing: :primary_shard_one },
shard_two: { writing: :primary_shard_two }
}
end
class Person < ShardRecord
end
ShardRecord.connected_to(role: :writing, shard: :shard_one) do
Person.create!
end
The guide also documents a ShardSelector middleware that picks the shard per request from a resolver you write, and says lock should stay true for tenant-based sharding so code cannot switch tenants mid-request.
# config/initializers/multi_db.rb, generated by bin/rails g active_record:multi_db
Rails.application.configure do
config.active_record.shard_selector = { lock: true }
config.active_record.shard_resolver = ->(request) { Tenant.find_by!(host: request.host).shard }
end
Each shard is its own entry in config/database.yml, declared on the model with connects_to, so this is database per tenant with Rails' own tools.
How data leaks across tenants
Row-level tenancy fails open. When no tenant is set, acts_as_tenant applies no scope, and require_tenant is false by default. Leaks come from three places:
-
Unscoped queries.
unscopedremoves the tenant scope along with the one you meant to drop. Raw SQL (find_by_sql,connection.execute) skips it too, andupdate_columnsskips the check that stops a record moving to another tenant. -
Background jobs. Jobs run outside the request, so the controller never set a tenant. acts_as_tenant stores the tenant with ActiveJob jobs and restores it when they run. Sidekiq needs
require "acts_as_tenant/sidekiq"in an initializer. -
The console.
bin/rails consolestarts with no tenant, soProject.allreturns every tenant's projects.
Turn on require_tenant so a missing tenant raises instead of returning everything:
# config/initializers/acts_as_tenant.rb
ActsAsTenant.configure do |config|
config.require_tenant = true
end
Queries without a tenant now raise ActsAsTenant::Errors::NoTenantSet. For code that should see every tenant, wrap it in ActsAsTenant.without_tenant { ... } so the exception is visible in review.
Testing for leaks
Write a request spec with two tenants and assert that one cannot load the other's record:
RSpec.describe "Projects", type: :request do
it "does not show another account's project" do
mine = create(:account)
theirs = create(:account)
project = ActsAsTenant.with_tenant(theirs) { create(:project) }
sign_in create(:user, account: mine)
get project_path(project)
expect(response).to have_http_status(:not_found)
end
end
Cover every resource a tenant can reach, including JSON endpoints and exports. The 404 comes from find raising ActiveRecord::RecordNotFound; if your test environment re-raises exceptions, assert that instead. Reset ActsAsTenant.current_tenant after each test, as the README shows, so one test's tenant does not mask a leak in the next.
Tenancy is not permissions
Tenancy decides which tenant's rows are in scope; who may do what inside them is a separate layer, covered by role-based access control and record-level permissions.
Multitenancy in an Avo app
If your team builds its admin with Avo, the multitenancy docs show how to tell it which tenant a request is for. Avo::Current has tenant_id and tenant attributes that you set in a before_action. The page gives two recipes: route-based, with Avo mounted under /:tenant_id, and session-based, with an account switcher.
Frequently asked questions
What is multitenancy in simple terms?
Multitenancy means one application serves many customers, and each customer sees only its own data. The customers, called tenants, share the code and servers but not the records. A tenant is usually a company or team, so several users share one tenant.
Should I use row-level, schema or database per tenant in Rails?
Start with row-level tenancy, a tenant column on every table, unless a customer or regulator requires physical separation. It is the cheapest to run and makes cross-tenant reporting a normal query. Schema per tenant and database per tenant isolate data in the database itself, at the cost of migrations and operations that grow with the tenant count.
What is the difference between acts_as_tenant and Apartment?
acts_as_tenant does row-level tenancy: every tenant shares the same tables and each query is filtered by a tenant column. Apartment does schema per tenant on PostgreSQL, or database per tenant, and switches the connection between them. Choose the isolation model first and the gem follows.
How do I stop data leaking between tenants?
Make a missing tenant an error, for example with acts_as_tenant's require_tenant option, so queries cannot run unscoped by accident. Check background jobs, console sessions and raw SQL, since those run outside the normal request scoping. Then write request specs with two tenants that assert one cannot load the other's records.
Can an admin see data across all tenants?
Only if you build it that way. With acts_as_tenant, staff tools can run inside without_tenant or be exempted from require_tenant by path. Keep that access separate from tenant users and limit it with roles and permissions.