acts_as_tenant: row-level Rails scoping that fails closed when the tenant is missing
Easy multi-tenancy for Rails in a shared database setup.
At a glance
- What is it?
- acts_as_tenant scopes Rails models to a current tenant set per request, per block or per subdomain, and it can raise instead of querying unfiltered when no tenant is set. That last option is the difference between a comfortable setup and a safe one, and it is a configuration flag.
- Who is it for?
- Adopt acts_as_tenant when your tenants share a database and you want Rails-native scoping rather than a schema-per-tenant arrangement, and turn on the require_tenant option from day one so a missing tenant raises instead of returning every tenant's rows.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 16 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on November 6, 2026, and from our analysis. They are not legal advice.
Star history
- ErwinM/acts_as_tenant1,713 ★ · +3
| Repository | 2026-10-16 | 2026-11-06 |
|---|---|---|
| ErwinM/acts_as_tenant | 1.7k | 1.7k |
3 Stars in the last 21 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Shared database, tenant_id on every model that matters
acts_as_tenant adds the ability to scope models to a tenant in a Ruby on Rails application. Tenants are represented by a tenant model such as Account, and the gem helps set the current tenant on each request and ensures tenant models stay scoped when viewing, searching and creating.
The strategy is row-level multitenancy, and the README is explicit about the alternative it is rejecting. With row-level, each model carries a tenant ID column, which makes filtering records per tenant ordinary column and index work. With schema multitenancy, each tenant gets its own database schema with its own copy of the tables, migrations must run against every tenant, and scaling gets harder as tenants are added. The README points at the Apartment gem as the schema approach; acts_as_tenant is the row-level one.
That choice is the whole architecture. It is cheap to add a tenant and cheap to migrate, and it means every query in your app now depends on a piece of global state being set correctly.
The tenant_id column and its index are yours to add
Scoping a model takes two things: the column, and the declaration. The migration is ordinary, and the index is not optional in practice:
class AddAccountToProjects < ActiveRecord::Migration
def up
add_column :projects, :account_id, :integer
add_index :projects, :account_id
end
end
class Project < ActiveRecord::Base
acts_as_tenant(:account)
endThe `acts_as_tenant(:account)` call names the association the scope keys on. Since every scoped query now filters on that column, the index is what keeps tenant filtering from turning into a table scan as the table grows.
Installation is a single Gemfile line:
gem 'acts_as_tenant'The gem is MIT licensed and the repository carries a MIT-LICENSE file, an Appraisals file, gemfiles/ for testing against multiple dependency sets, and a spec/ directory, which is the standard shape for a gem that has to work across a range of Rails versions.
Three ways to set the tenant, and each has a failure mode
The README frames the whole setup as two steps: set the current tenant, and scope the models. The first step has three routes.
By subdomain is the declarative one, and it is one class macro:
class ApplicationController < ActionController::Base
set_current_tenant_by_subdomain(:account, :subdomain)
endThat tells the gem to use the last subdomain to identify the tenant, that tenants are Account records, and that the Account model has a subdomain column to look up. If you omit the parameters they default to those values. By default the last subdomain is used, and `subdomain_lookup: :first` switches to the first, which matters for a domain like `admin.example.com` where position carries meaning.
There is a fallback variant, `set_current_tenant_by_subdomain_or_domain(:account, :subdomain, :domain)`, which checks for a subdomain and falls back to the domain. That is the one to reach for when the same application serves tenant subdomains and a bare apex domain.
The manual route is a filter, and it is the most explicit:
class ApplicationController < ActionController::Base
set_current_tenant_through_filter
before_action :your_method_that_finds_the_current_tenant
def your_method_that_finds_the_current_tenant
current_account = Account.find_it
set_current_tenant(current_account)
end
endDeclaring `set_current_tenant_through_filter` is what tells acts_as_tenant you will supply the tenant yourself. Order matters more than it looks: when you do this in a specific controller rather than the application controller, the README says the declaration has to be at the top of the file, so the tenant is set before any other code runs.
with_tenant and without_tenant are the block forms background jobs need
Not every code path is a controller. Background workers have no request, so the block form exists:
ActsAsTenant.with_tenant(current_account) do
# Current tenant is set for all code in this block
endThe README suggests this for the run method of a worker, so everything the job touches is scoped. It also states that all methods that set the current tenant are thread safe, which matters because a worker pool running several jobs in different threads is exactly where a global current-tenant variable would otherwise become a race.
The inverse block turns scoping off deliberately:
ActsAsTenant.without_tenant do
# Tenant checking is disabled for all code in this block
endThat is for shared routes such as an admin panel or an internal dashboard when require_tenant is enabled app-wide, since without it those routes would raise for want of a tenant.
And a third block relaxes a different rule, allowing a model's tenant to be changed:
ActsAsTenant.with_mutable_tenant do
# Tenant updating is enabled for all code in this block
endThe README positions this for admin screens where it is acceptable for certain users to move an existing record to another tenant. It is the one block here that relaxes a protection rather than adding one, so it deserves the same scrutiny as the code it wraps.
If no tenant is set, nothing is scoped, and that is the default trap
The README states the failure mode in a note that deserves more attention than its placement: if the current tenant is not set by one of the documented methods, acts_as_tenant cannot apply the proper scope to your models. In other words, an unset tenant is not an error by default, it is an unscoped query.
What that means in practice is that a controller action reached without a tenant filter returns rows across every tenant. That is the classic multi-tenant data leak, and the gem's protection is a configuration flag rather than a default.
The option is to require the tenant to be set at all times, which makes acts_as_tenant raise an error when a query runs without a tenant available. Combined with `without_tenant` for the routes that legitimately have no tenant, this inverts the default from fail-open to fail-closed. Any application running this gem should be configured that way from the first commit, because retro-fitting it turns every previously silent unscoped path into an exception.
The README also lists protection against various types of nastiness directed at circumventing the tenant scoping, which is the other half of the story, and the walkthrough video linked from the repository is the fastest way to see the whole flow in one sitting.
Uniqueness needs its own validator, scoped to the tenant
Beyond scoping, the gem adds a method to validate uniqueness within a tenant, `validates_uniqueness_to_tenant`. Standard Rails uniqueness validation is global across the table, so without something like this two tenants cannot both have a Project called the same thing.
That constraint is also a correctness issue rather than a nicety, because the database index backing a global uniqueness check would reject a legitimate second tenant. Scoping the validation to the tenant matches both the data model and the index you would want.
The gem also sets up a helper method containing the current tenant, so application code can ask what it is operating on rather than reaching into instance state.
A 2.x line moving fast, with three releases in ten days
The release history is dense. v2.0.2 was tagged on 2026-09-28, v2.1.0 on 2026-10-06 and v2.2.0 on 2026-10-07, and the last push to the repository was on 2026-10-16.
Three releases inside ten days across a major version boundary is worth planning for. A 2.0 line usually carries breaking changes to the scoping API, and a rapid patch sequence on top of it means the notes in CHANGELOG.md matter more than usual. Pin the version in your Gemfile rather than accepting whatever resolves, and read the changelog between 2.0.2 and whichever 2.x you adopt.
The repository supports multi-Rails testing through the Appraisals file and gemfiles/ directory, with Rakefile and spec/ alongside, and a.standard.yml for the Ruby formatter. The project also documents its origin in a blog post included in the repository, which is a reasonable place to find the reasoning behind the design if you want more than the README.
Editorial conclusion
Adopt acts_as_tenant when your tenants share a database and you want Rails-native scoping rather than a schema-per-tenant arrangement, and turn on the require_tenant option from day one so a missing tenant raises instead of returning every tenant's rows. Do not adopt it for workloads where you expect thousands of tenants, since the README notes that schema approaches make migrations harder to run as tenants multiply and row-level keeps that cost down but concentrates it in a tenant_id column and index. Verify first which models are actually declared acts_as_tenant, because a model that is not declared is not scoped and will not raise.
Frequently asked questions
How do I add acts_as_tenant to a Rails application?
Add `gem 'acts_as_tenant'` to the Gemfile, add a tenant id column with an index to each scoped table, and declare `acts_as_tenant(:account)` on the model, for example a migration that runs `add_column :projects, :account_id, :integer` followed by `add_index :projects, :account_id`.
How do I set the current tenant in acts_as_tenant?
Three ways: `set_current_tenant_by_subdomain(:account, :subdomain)` for subdomain lookup, `set_current_tenant_by_subdomain_or_domain` to fall back to the domain, or `set_current_tenant_through_filter` with a before_action calling `set_current_tenant`. For background work use `ActsAsTenant.with_tenant(current_account) do ... end`.
What happens if no tenant is set in acts_as_tenant?
The README warns that acts_as_tenant cannot apply the proper scope to your models if the current tenant is not set, which means unscoped queries. To avoid that, configure acts_as_tenant to require the tenant to be set at all times so a query without one raises an error, and use `ActsAsTenant.without_tenant` for routes that legitimately have no tenant.
How does acts_as_tenant differ from the Apartment gem?
acts_as_tenant uses row-level multitenancy, where each model has a tenant id column and queries filter on it. Apartment uses schema multitenancy, where each tenant has its own database schema, migrations must run against every tenant, and it is harder to scale as tenants are added.
How do I allow an admin screen to change a record's tenant?
Wrap the operation in `ActsAsTenant.with_mutable_tenant do ... end`. The README describes this as allowing a model's tenant to be changed, useful for admin screens where certain users may move an existing model to another tenant in specific cases.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/erwinm-acts-as-tenant)Star history chart for your README
[](https://hysenlabs.com/projects/erwinm-acts-as-tenant)Was this useful?