Multi tenancy in Rails: the decisions that are expensive to reverse
Somewhere in the first migration of every SaaS product a column called company_id appears, and nobody discusses it. That column, and the dozen decisions that should be made alongside it, decide years later whether one large customer can have its own database in three weeks or only after a rewrite. In this article I walk through the multi tenancy choices in a Rails 8.1 application that take an afternoon to make well and months to unmake, with working code for Postgres row level security, background jobs and sharding, using an invented Manchester invoicing product.
The column that was added on a Tuesday
There is a category of decision in software that does not feel like a decision at the time. You are three weeks into building a SaaS product, you need customers to sign up, and somewhere in the first migration you add a column called company_id to a table. Nobody discusses it. It is obvious.
Years later the product has a few thousand customers, and one of them, large enough to matter, asks in a procurement call for its data to sit in its own database in Frankfurt. Whether the answer is "yes, three weeks" or "not without rewriting half the product" was settled by that column, and by the dozen small decisions that should have been made alongside it and were not.
This article is about those decisions in a multi tenant Rails application: the ones that take an afternoon to make well and months to unmake. I have written it for founders as much as for developers. You do not need to understand Postgres schemas to understand what each choice costs you in year four, so where I get technical I say why it matters to the business first. The code samples are written for Rails 8.1, the current release line (8.1.4.1 at the time of writing)1, and PostgreSQL 18.
To keep it concrete I will use an invented product throughout: Ledgerline, an invoicing SaaS from Manchester at ledgerline.example. Its customers are small firms, a Leeds bakery, a Bristol architects' practice, a Brighton agency that runs the books for nine clients. Each of them must see its own invoices and nobody else's.
What a tenant actually is
Start with the word, because half the confusion comes from using it loosely.
A tenant is the unit of data isolation. It is the boundary inside which data may be seen, and outside which it must not. When someone at the Leeds bakery logs into Ledgerline, they see the bakery's invoices and nobody else's. That is the whole idea.
A tenant is not automatically the same thing as a customer, a user, a company or a billing account, and the moment you treat them as one you have made a decision you will regret.
Take the Brighton agency. It manages nine clients through Ledgerline. Is the agency the tenant, or is each client? If the agency is the tenant, the nine clients' data sits in one pool and every agency employee sees all of it. If each client is a tenant, the agency's staff need to belong to nine tenants and switch between them. Both are legitimate. They are different products.
A group with a UK company and a German company that must not see each other's data, but one finance director who pays for both: two tenants, one billing account. A sole trader who signs up alone, hires two people and is then bought by a larger firm: one tenant that starts with one user and ends up owned by someone else.
If you have not decided what your tenant is, your code has decided for you, and it has almost certainly decided that tenant equals company equals billing account equals the thing with the subscription. That works right up until the first customer whose organisation is shaped differently, and there is always one.
So the first expensive decision is a definition. Write down what a tenant is in your product and what it is not, then make sure the data model reflects exactly that.
The three ways to keep tenants apart
There are three isolation models, and every SaaS ends up on one of them.
Shared schema with a tenant column. One database, one set of tables, and every row that belongs to a tenant carries a column saying which one. Every query filters on it.
The good: it is inexpensive to run. One database to back up, one to monitor, one migration to run when you change a table. Adding a tenant is inserting a row. Ten thousand tenants cost roughly the same to operate as ten. Cross tenant reporting, the kind you need for your own analytics, is a query.
The bad: on its own, the isolation is a promise your code makes, not a wall the database enforces. Forget the filter once and tenant A sees tenant B. Noisy neighbours are real: one customer running an enormous export slows everyone. Per tenant backup and restore is not something the database gives you, you build it. And when one customer asks for its data to live in Frankfurt while everyone else is in London, the answer is that it does not, at least not yet.
Schema per tenant. One database, but each tenant gets its own Postgres schema, a namespace holding a full copy of every table. The application switches the search path to the tenant's schema on each request, and the queries look like single tenant code.
The good: the isolation is structural. A query in the wrong schema finds nothing rather than someone else's data. Per tenant restore is possible because you can dump a schema. Developers new to multi tenant software find it intuitive.
The bad: migrations. Every schema change runs once per tenant. At ten tenants that is a loop. At a thousand it is a long deployment that can fail halfway, leaving some customers on the new structure and some on the old. Every tenant multiplies the number of tables Postgres has to track, connection handling gets complicated, and tooling that assumes one schema breaks in interesting ways.
In Rails this model has a history worth knowing. The original Apartment gem, the best known implementation, has not had a release since June 20192. A fork, ros-apartment, is maintained (by CampusESP since 2024, with 3.4.4 the current stable release and a version 4 in alpha that needs Rails 8.1)34. Its own README is refreshingly honest: it recommends row level tenancy instead if you have hundreds of tenants or more, need cross tenant queries, or are starting a new project3. When the maintainers of the schema per tenant gem tell you to consider the other model for a new product, that is worth hearing.
Database per tenant. Each tenant gets an entire database, potentially on its own server.
The good: complete isolation, the kind you can point to in a security questionnaire. Data location is simple: the German customer's database is in Germany. Per tenant restore, per tenant scaling, per tenant everything. A customer can leave and take its database with it.
The bad: cost and operations. Every database needs connections, monitoring, backups and upgrades. Your infrastructure bill grows with your customer count rather than your usage. Migrations run once per database and can fail per database. Anything across tenants becomes a data warehouse project. Signing up a customer means provisioning infrastructure. This model fits a product with fifty customers paying tens of thousands a year each. It is a slow death for a product with five thousand customers paying fifty pounds a month.
There is a fourth option once you outgrow a single Postgres server: Citus, the Postgres extension that spreads data over several nodes. It supports both row based sharding, where tables are distributed by a tenant column, and, since Citus 12.0, schema based sharding, where each tenant schema becomes a shard5. It is a way to scale whichever of the first two models you chose, not a way to avoid choosing.
How each model fails at ten, a hundred and ten thousand
It helps to think about where each model breaks, because the breaking point decides the cost of reversing.
At ten tenants everything works. Shared schema is fine. Schema per tenant is fine and feels safer. Database per tenant is fine and feels enterprise. This is the point at which the decision gets made, and it is the point at which every option looks equally good, which is exactly the problem.
At a hundred tenants schema per tenant starts to show its migration cost, but it is manageable. Database per tenant is now a meaningful infrastructure bill and a real operations burden, and if your tenants pay you thirty pounds a month you may be losing money on each one. Shared schema has probably had its first scare: a query someone forgot to scope, caught in code review or, worse, by a customer.
At ten thousand tenants only shared schema is still standing without a rewrite. Schema per tenant has become a job for one person and a source of deployment fear. Database per tenant at this scale is a hosting company, not a SaaS.
Now the reversal costs. Moving one customer from a shared schema to its own database is possible if you were disciplined, and I will come back to how. Moving from schema per tenant to a shared schema touches every table, every query and every deployment script. Moving from database per tenant to anything else means merging thousands of databases and remapping every primary key that collides.
This is why I default to a shared schema. Not because it is the safest on day one, it is not, but because it is the only one whose weaknesses can be fixed in place rather than by starting again. The rest of this article is mostly about fixing those weaknesses before they bite.
Where the tenant lives in the code
If you go with a shared schema, the next expensive decision is where the current tenant lives and how queries find it. In Ledgerline the tenant model is called Account.
The current tenant lives in exactly one place. Rails has a mechanism for this called current attributes: a per request container that Rails resets automatically before and after each request6. You set it once, at the edge, when the request comes in and the user is identified, and everything else asks that one place. There is no second way to find out which tenant you are in.
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :session, :account
delegate :user, to: :session, allow_nil: true
end
The session attribute is what the Rails 8 authentication generator sets up. The account is yours to add.
Every query goes through the tenant. You do not ask for all invoices and filter. You ask the current account for its invoices. In Rails terms that is an association, and it means the tenant id is in the query because it structurally cannot be missing.
class Account < ApplicationRecord
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
has_many :invoices, dependent: :destroy
end
class InvoicesController < ApplicationController
def index
@invoices = Current.account.invoices.order(issued_on: :desc)
end
def show
@invoice = Current.account.invoices.find(params[:id])
end
end
Code that does not go through Current.account looks wrong on the page, which is the point: reviewers spot it. An Invoice.find(params[:id]) in a controller is a review comment waiting to happen.
The tempting alternative is a default scope, telling every model to add the tenant filter to every query automatically. The trouble is that a default scope is invisible on the page, it is removed by a single unscoped, and when no tenant is set it quietly returns everything. That last behaviour is the dangerous one.
This is worth stating honestly, because the best known gem in this space, acts_as_tenant, is built on exactly that mechanism. Its README says plainly that it uses Rails' default_scope, that queries are not scoped when no tenant is set unless require_tenant is enabled, and that unscoped removes the protection7. The gem is actively maintained (2.2.2, October 2026)8 and does a lot of useful work for you, including saving the current tenant into Active Job arguments and restoring it when the job runs7. If your team prefers it, use it, and switch on config.require_tenant = true on day one so that a query without a tenant raises an error rather than reading every row. The decision that is expensive to reverse is not gem versus hand rolled. It is whether a missing tenant fails loudly or silently.
Row level security: the database as the second lock
The third layer is the database itself. Postgres has a feature called row level security: you attach a policy to a table, and every query against it only sees the rows the policy allows. If a table has row level security enabled and no policy at all, Postgres denies everything by default9. That makes it the right shape for tenancy: if the application forgets to say who it is, the answer is nothing, not everything.
The policy compares the tenant column with a setting the application puts on the connection:
# db/migrate/20261106090000_add_tenant_policy_to_invoices.rb
class AddTenantPolicyToInvoices < ActiveRecord::Migration[8.1]
def up
execute <<~SQL
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (account_id = NULLIF(current_setting('app.current_account_id', true), '')::uuid)
WITH CHECK (account_id = NULLIF(current_setting('app.current_account_id', true), '')::uuid);
SQL
end
def down
execute <<~SQL
DROP POLICY tenant_isolation ON invoices;
ALTER TABLE invoices NO FORCE ROW LEVEL SECURITY;
ALTER TABLE invoices DISABLE ROW LEVEL SECURITY;
SQL
end
end
Three details in that migration matter. The second argument to current_setting makes it return nothing instead of raising an error when the setting is missing10, and the NULLIF turns an empty value into nothing as well, so a connection without a tenant matches no rows. WITH CHECK stops anyone writing a row into another tenant. And FORCE exists because table owners normally bypass row level security, while superusers and roles with the BYPASSRLS attribute always do9. The cleanest setup is that the application connects as a role that owns nothing and bypasses nothing, and migrations run as a separate owner role.
Rails' default db/schema.rb cannot express database features such as triggers and stored procedures, and the Rails guides recommend switching to the SQL format when you use them11. Policies are in the same category, so set config.active_record.schema_format = :sql or your test database will be built without them.
Now the application has to set the value. The safe way is set_config with its third argument true, which makes the value last only until the end of the current transaction10. Nothing can then leak into the next request that borrows the same connection, and it keeps working behind a connection pooler such as PgBouncer in transaction mode.
# app/models/tenant_database_scope.rb
module TenantDatabaseScope
def self.with(account_id)
ApplicationRecord.transaction do
ApplicationRecord.with_connection do |connection|
connection.select_value(
ApplicationRecord.sanitize_sql_array(
["SELECT set_config('app.current_account_id', ?, true)", account_id.to_s]
)
)
end
yield
end
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_account
around_action :scope_database_to_account
private
def set_current_account
membership = Current.user.memberships.find_by!(account_id: session[:account_id])
Current.account = membership.account
end
def scope_database_to_account(&action)
TenantDatabaseScope.with(Current.account.id, &action)
end
end
The tables that decide who you are, users, accounts and memberships, stay outside the policy, because the application has to read them before it knows the tenant. Everything that belongs to a tenant goes inside.
The price of this approach is that each request runs inside one transaction, which holds a database connection for the length of the request. For a typical SaaS that is fine. For long running work, set the tenant per unit of work rather than around the whole thing. It takes about a day to set up properly, and from then on an unscoped query returns nothing instead of everything. I would not run a shared schema SaaS without it.
The places tenants leak that nobody thinks about
The request cycle is the easy part. The tenant arrives with the user, you set it, you scope the queries. The leaks happen everywhere that is not a request.
Background jobs. A job is enqueued during a request, with the tenant set. It runs later in a worker process where nothing is set, because Rails resets current attributes around each job too. With row level security, a job without a tenant does nothing. Without it, and with a default scope, it can do everything. Every tenant job needs the tenant in its arguments and needs to restore it when it runs:
class ApplicationJob < ActiveJob::Base
end
class TenantJob < ApplicationJob
# Every tenant job passes the account as its first argument.
around_perform do |job, block|
account = job.arguments.first
Current.set(account: account) do
TenantDatabaseScope.with(account.id, &block)
end
end
end
class SendInvoiceRemindersJob < TenantJob
def perform(account)
account.invoices.overdue.find_each(&:send_reminder)
end
end
acts_as_tenant does the Active Job part for you, and has Sidekiq middleware for plain Sidekiq workers7. The wider discipline of jobs that fail safely is in my piece on background workers, and this is the multi tenant addendum to it.
Caches. A cached invoice fragment keyed on the invoice record is safe, because the record's cache key includes its unique id. The danger is everything you cache that is not a record: the dashboard total, the list of overdue invoices, a response from a third party API. A key like "dashboard_totals" without the tenant serves the bakery's numbers to the architects. Put the account in every such key:
Rails.cache.fetch([Current.account, "dashboard_totals"], expires_in: 10.minutes) do
DashboardTotals.new(Current.account).to_h
end
File storage. Active Storage already gives every uploaded file a random key of at least 28 characters, so guessing another tenant's file is not the realistic risk12. The realistic problem comes later: when a customer leaves and asks you to delete everything, you need to find every file of theirs, and a flat bucket of random keys does not tell you who owns what. You can pass your own key when you create a blob12, so prefix it with the tenant and per tenant export and erasure become a prefix operation in the bucket:
blob = ActiveStorage::Blob.create_and_upload!(
io: pdf_io,
filename: "invoice-#{invoice.number}.pdf",
key: "accounts/#{Current.account.id}/#{ActiveStorage::Blob.generate_unique_secure_token}"
)
invoice.pdf.attach(blob)
Search indexes. Whether you use Meilisearch, Elasticsearch or Postgres full text search, the tenant filter must be applied on the server for every search, never left as a parameter the caller is trusted to add. The search endpoint is a classic place for one tenant's results to show up in another's.
Logs and error reports. Less about leaking to customers, more about your own obligations. A log line with a customer's personal data and no tenant id cannot be found and deleted when that customer leaves.
Primary keys. Sequential integer ids reveal the size of your business and invite a curious customer to try the next number. Only your scoping stands between that customer and the record. Use UUIDs, and since PostgreSQL 18 the database can generate time ordered version 7 UUIDs itself with uuidv7(), which keep the index friendliness of sequential ids without being guessable in sequence13. In a migration:
create_table :invoices, id: :uuid, default: -> { "uuidv7()" } do |t|
t.references :account, type: :uuid, null: false, foreign_key: true, index: true
t.string :number, null: false
t.date :issued_on, null: false
t.timestamps
end
This is not strictly a tenancy decision, but it is expensive to change later, because every link in every email you have ever sent contains the old form.
Users who belong to more than one tenant
The second most common expensive mistake, after the silent default scope, is putting the tenant id on the users table.
It seems obvious. A user works for a company, so a user belongs to a tenant. Except the Brighton agency: its staff need to be in nine. The bookkeeper who advises three of your customers and wants one login. The founder who runs two businesses on your product. The customer that gets acquired and now has staff from the parent company needing access. Your own support engineer who needs to see into an account with the customer's permission.
Any SaaS that lives long enough meets every one of these. With the tenant id on the user, each one becomes a workaround, usually several accounts per person, or a migration.
The model that survives is simple. Users are global, tenants are global, and a membership table in between says which users belong to which tenants with which role:
class Membership < ApplicationRecord
belongs_to :user
belongs_to :account
enum :role, { viewer: 0, member: 1, admin: 2 }
validates :user_id, uniqueness: { scope: :account_id }
end
class User < ApplicationRecord
has_many :memberships, dependent: :destroy
has_many :accounts, through: :memberships
end
A user signs in once, sees the accounts they belong to, picks one, and the session carries that choice. An invitation is a membership waiting to be claimed. The role lives on the membership, not on the user, because the same person can be an admin in one account and a viewer in another.
Single sign on comes later, and this model makes it possible. When a larger customer wants its staff to sign in through its own identity provider, you attach the provider to the account, and the membership table already knows who is allowed in. With the tenant id on the user, single sign on means rebuilding authentication.
This costs perhaps a day more than the naive version at the start.
Configuration, feature flags and plans
Tenants are not identical. One is on the larger plan, one is on a trial, one has a feature you switched on by hand because they asked nicely.
The expensive mistake is putting that in code: a conditional that checks for one special customer, then another, and a few years later a file nobody dares touch called tenant_overrides.rb.
The version that ages well is boring: a settings model per tenant with typed values, and a feature flag system that resolves flags per tenant with sensible defaults. Both can be changed from an admin screen by someone who is not a developer, and both are readable in one place when a customer asks why their account behaves differently. For flags, Flipper is the gem I reach for, and it is actively maintained14.
The related decision is what a plan is. A plan is not a tenant attribute. A plan is a bundle of limits and flags, and a tenant is on one. If plan names are hard coded, the day marketing renames the plans is a code change. If plans are data and tenants point at them, it is an admin edit.
The tenant is not the thing that pays
A billing account is the entity that has a payment method, receives invoices and owes you money. A tenant is the entity whose data is isolated. Usually they line up one to one. Not always, and the cases where they diverge tend to be the customers who pay you the most.
The holding company that pays for five subsidiaries on one invoice. The agency that pays for its clients' accounts and recharges them. The large customer that wants the tenant to exist before procurement has finished, with billing attached six weeks later. The reseller.
If the subscription hangs off the tenant, every one of these is a workaround. If a tenant belongs to a billing account and a billing account has many tenants, every one of these is a row. Changing from the first to the second later is not much code, but it is a lot of data, because every historic invoice has to be reattached, and your accountant will have views on that.
Decide it on day one. It is one extra table.
Why the isolation model is also your GDPR strategy
The legal side and the engineering side are the same decision seen from two angles.
When your customers put personal data into your product, they are usually the controller and you are their processor. Under the UK GDPR, as under the EU original, the contract between you must say, among other things, that you delete or return all personal data at the controller's choice when the service ends, and that you do not engage another processor without the controller's written authorisation15. As a processor you also keep records of the processing you carry out for each controller16. And when something goes wrong, you must tell the customer without undue delay, because they have 72 hours to report a personal data breach to the regulator, which in the UK is now the Information Commission (formerly the ICO)1718.
Every one of those obligations is easy or hard depending on your isolation model.
Export. In a shared schema, exporting a tenant is a query per table filtered by tenant plus a prefix listing in file storage. If the tenant column really is everywhere, it is a script. If it is not, it is archaeology.
Erasure. The same, and this is where the cache and log leaks come back. If a customer's personal data sits in a log line without a tenant id, you cannot show that you deleted it.
Location. The GDPR does not require data to stay in one particular country, but transfers out of the UK or the EEA need a legal basis, and many customer contracts go further and name a region. If a German customer's contract says Frankfurt, a shared schema in London does not meet it, and no amount of row level security changes that. Only a dedicated database meets that kind of clause natively, which is why the next section exists.
Records. Your records have to say what you hold for whom, where, and which sub processors touch it. An accounts table with a data location column and a clear list of sub processors is most of that record. A pile of special cases is not.
I went deeper into the AI version of this in building GDPR compliant AI features in your SaaS, and everything in that piece assumes the tenancy underneath is right. You cannot bolt data protection onto a product that does not know whose data is whose.
The moment a customer asks for its own database
It will happen. A customer large enough to matter will say, in a security review or a procurement call, that its policy requires a dedicated database, a specific country, or both.
Whether you can say yes without a rewrite depends on decisions made years earlier. The tenant id must never have leaked into places it does not belong: not into hard coded joins across tenants, not into reporting code that assumes all tenants live in one database. If the application only ever reaches tenant data through the current account, then which database the current account lives in is a detail that can vary.
Rails supports this natively through horizontal sharding: several databases with the same structure, declared in config/database.yml, between which a model can switch with connected_to(role:, shard:)11. The ShardSelector middleware can pick the shard per request, and the Rails guide is explicit that for tenant based sharding its lock option should always be true, so code cannot switch tenants halfway through a request11. For Ledgerline's first dedicated database that looks like this:
# config/database.yml (production)
production:
primary:
database: ledgerline_production
primary_frankfurt:
database: ledgerline_frankfurt
host: db.frankfurt.internal
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to shards: {
default: { writing: :primary },
frankfurt: { writing: :primary_frankfurt }
}
end
# config/initializers/multi_db.rb (created 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) {
ApplicationRecord.connected_to(shard: :default, role: :writing) do
AccountDirectory.find_by!(host: request.host).shard.to_sym
end
}
end
The pattern that works: most tenants live in the shared database, a small directory in the default database says which shard each tenant lives on, and the switch happens in the same place the tenant is set. Everything below that line neither knows nor cares. The same migrations run against every shard, and the row level security policy keeps working inside each of them.
You do not build this on day one. You build the discipline that makes it possible on day one, and you build the switch the first time a customer is willing to pay for it. A product with explicit scoping, a tenant column on every table and UUIDs can move one tenant in weeks. A product with silent default scopes and integer ids in every link has to be rebuilt first.
The one test every SaaS needs
I build test first, and I wrote at length about why in my guide to TDD and BDD in Rails, so I will keep this to the specific point.
Most test suites for multi tenant products prove that things work. Very few prove that things do not work across the boundary. That is the test that matters, because a broken feature is a bug report, while a tenant leak is a personal data breach with a 72 hour clock on your customer's side.
The test is simple to describe. Create two tenants. Create data in both. Sign in as a user of tenant A. Try every way you can think of to reach tenant B's data: the list pages, the detail pages with tenant B's ids, the search, the export, the API, a job with a forged argument. Assert that every one returns nothing or not found. Then do the same in the other direction. In RSpec with factory_bot, one case looks like this:
# spec/requests/tenant_isolation_spec.rb
RSpec.describe "Tenant isolation", type: :request do
let(:bakery) { create(:account, name: "Leeds bakery") }
let(:architects) { create(:account, name: "Bristol architects") }
let!(:their_invoice) { create(:invoice, account: architects) }
before { sign_in_as create(:user, accounts: [bakery]) }
it "does not show another tenant's invoice" do
get invoice_path(their_invoice)
expect(response).to have_http_status(:not_found)
end
it "keeps the database honest even without application scoping" do
TenantDatabaseScope.with(bakery.id) do
expect(Invoice.where(id: their_invoice.id)).to be_empty
end
end
end
sign_in_as is your own test helper. The second example runs against the database rather than the controller: with the bakery set, a raw query with no application filter must not find the architects' invoice. That test proves that even if the application is wrong, the database is right. If it fails on your machine, check whether your development database user is a superuser, because superusers bypass every policy9.
Write the scenarios as a Cucumber feature if you want the business to read them, because this is one the business should read. Run it on every commit and add a case every time you add an endpoint. It is tedious. It is the highest value test in the suite.
How I make these choices on the SaaS products I build
The deciding question is almost always the same: does the product itself need to look across tenants? A benchmark that compares one customer with others like it, a marketplace, an agency view across clients, your own revenue reporting. If the answer is yes, and for most SaaS products it is, database per tenant turns the core feature into a warehouse project and schema per tenant makes it painful. A shared schema with explicit scoping, row level security underneath, and one clearly marked place in the code where aggregate queries across tenants are allowed matches what the product actually is.
The other defaults follow from the earlier sections. Users are global with memberships, because some customer will always need one person in several accounts. Each account carries a field for where its data lives, even while every value is the same, because changing a value later is easier than changing a schema. None of this takes longer than the naive version. It takes thinking for an afternoon before the first migration instead of after the first difficult procurement call.
The list, for the founder in a hurry
If you only read one section, read this one and give it to whoever is building your product.
- Define what a tenant is in your product, in writing, before the first migration.
- Shared schema with a tenant column, unless you have a specific reason not to, and "it feels safer" is not a reason.
- The current tenant lives in one place, set at the edge of each request and each job.
- Every query goes through the tenant. If you use a default scope, a missing tenant must raise an error.
- Postgres row level security as the layer under the application, with the SQL schema format so the policies reach every environment.
- The tenant in every cache key, file path, search filter, job argument and log line.
- Users are global. Memberships join users to tenants and carry the role.
- A tenant belongs to a billing account, and a billing account has many tenants.
- Configuration and plans are data with an admin screen, not conditionals in code.
- UUIDs, ideally time ordered, and no sequential ids in links.
- A data location field on the tenant from day one, even if every value is the same.
- The "tenant A cannot see tenant B" test, on every commit, growing with every endpoint.
Each of those is a day or less at the start. Each of them is weeks to months to retrofit with customers on the platform.
The honest closing
The column added on a Tuesday afternoon is not a mistake because the column is wrong. A tenant column is exactly right. It is a mistake because nobody decided it, so nobody decided the eleven things that go with it, and the product grew around the gaps.
All of this is inexpensive at the beginning: a membership table, a session setting, a policy, a test. The expensive version is the one where you discover the gaps through a customer. If you are at the beginning, spend the afternoon. If you are past it and something here made you wince, it is usually more fixable than it feels, and the order to fix it in is the order of the list above. If you would like a second pair of eyes on it, book a free workshop: the first two weeks of work are on me.
Footnotes
-
RubyGems.org, rails 8.1.4.1, released 4 November 2026, https://rubygems.org/gems/rails/versions/8.1.4.1 ↩
-
RubyGems.org, apartment (influitive), latest version 2.2.1, released 19 June 2019, https://rubygems.org/gems/apartment ↩
-
rails-on-services, Apartment (ros-apartment) README, sections "When to Use Apartment" and "About ros-apartment", GitHub, https://github.com/rails-on-services/apartment ↩ ↩2
-
RubyGems.org, ros-apartment versions (3.4.4 stable, 29 May 2026, latest 4.0.0 alpha15, 28 September 2026), https://rubygems.org/gems/ros-apartment/versions ↩
-
Citus Data, Concepts: sharding models (row based and schema based sharding, the latter available since Citus 12.0), https://docs.citusdata.com/en/stable/get_started/concepts.html ↩
-
Rails API documentation, ActiveSupport::CurrentAttributes, v8.1.4.1, https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html ↩
-
Erwin Matthijssen and contributors, acts_as_tenant README (default scope, require_tenant, Active Job and Sidekiq integration), GitHub, https://github.com/ErwinM/acts_as_tenant ↩ ↩2 ↩3
-
RubyGems.org, acts_as_tenant 2.2.2, released 22 October 2026, https://rubygems.org/gems/acts_as_tenant ↩
-
PostgreSQL 18 documentation, 5.9 Row Security Policies, https://www.postgresql.org/docs/current/ddl-rowsecurity.html ↩ ↩2 ↩3
-
PostgreSQL 18 documentation, 9.28 System Administration Functions (current_setting and set_config), https://www.postgresql.org/docs/current/functions-admin.html ↩ ↩2
-
Rails Guides, Multiple Databases with Active Record (horizontal sharding and automatic shard switching), https://guides.rubyonrails.org/active_record_multiple_databases.html and Active Record Migrations, section on the SQL schema dumper, https://guides.rubyonrails.org/active_record_migrations.html ↩ ↩2 ↩3
-
Rails API documentation, ActiveStorage::Blob (create_and_upload! with key, generate_unique_secure_token, MINIMUM_TOKEN_LENGTH 28), https://api.rubyonrails.org/classes/ActiveStorage/Blob.html ↩ ↩2
-
PostgreSQL 18 documentation, 9.14 UUID Functions (uuidv7), https://www.postgresql.org/docs/current/functions-uuid.html ↩
-
RubyGems.org, flipper 1.4.2, released 11 May 2026, https://rubygems.org/gems/flipper ↩
-
UK GDPR, Article 28 (processor), paragraphs 2 and 3(g), legislation.gov.uk, https://www.legislation.gov.uk/eur/2016/679/article/28 and for the EU text Regulation (EU) 2016/679, https://eur-lex.europa.eu/eli/reg/2016/679/oj ↩
-
UK GDPR, Article 30(2) (records of processing kept by the processor), legislation.gov.uk, https://www.legislation.gov.uk/eur/2016/679/article/30 ↩
-
UK GDPR, Article 33 (notification of a personal data breach), paragraphs 1 and 2, legislation.gov.uk, https://www.legislation.gov.uk/eur/2016/679/article/33 ↩
-
Data (Use and Access) Act 2025, section 118 (abolition of the office of Information Commissioner) and section 119, in force 30 September 2026, legislation.gov.uk, https://www.legislation.gov.uk/ukpga/2025/18/section/118 ↩