Omaship

February 26, 2026 · 13 min read

Multi-Tenancy in Rails 8: Row-Level, Schema-Based, or Database-Per-Tenant for Your SaaS in 2026

Jeronim Morina

Jeronim Morina

Founder, Omaship

Multi-tenancy is one of those decisions you make early and live with forever. Get it wrong, and you are either leaking data between tenants or rewriting your database layer six months in. Get it right, and it disappears into the background while you focus on building features.

Most Rails SaaS guides hand-wave over multi-tenancy with "just use acts_as_tenant" or "PostgreSQL schemas solve everything." Neither is universally true. The right pattern depends on your data isolation requirements, your scale trajectory, and how much operational complexity you are willing to absorb.

This guide breaks down the three multi-tenancy patterns that matter in 2026, when each makes sense, how to implement them in Rails 8, the pitfalls that cause data leaks, and how SQLite multi-database support changes the calculus for indie hackers shipping on a single server.

The three patterns, honestly compared

Every multi-tenant architecture falls into one of three categories. Each trades off isolation, complexity, and operational overhead differently. There is no universally correct answer -- only the answer that fits your specific constraints.

Pattern Isolation Complexity Best For
Row-level (shared DB) Application-enforced Low Most SaaS apps, 0 to $1M ARR
Schema-per-tenant Database-enforced Medium Compliance-heavy B2B, regulated industries
Database-per-tenant Full physical isolation High Enterprise SaaS, data residency requirements

Row-level tenancy: the right default

Row-level tenancy means all tenants share the same database and the same tables. Every tenant-scoped table has an account_id (or team_id, organization_id -- pick one and stick with it) column, and every query filters by that column.

This is the pattern that 90% of SaaS applications should use. It is simple, it works with every Rails feature out of the box, and it scales further than you think.

Implementation with Current attributes

Rails has a built-in mechanism for request-scoped global state: Current attributes. Use it to set the current tenant early in the request lifecycle.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :account

  resets { Time.zone = "UTC" }

  def account=(account)
    super
    # Set time zone, locale, or other tenant-specific config here
    Time.zone = account.time_zone if account&.time_zone
  end
end
# app/controllers/concerns/set_current_account.rb
module SetCurrentAccount
  extend ActiveSupport::Concern

  included do
    before_action :set_current_account
  end

  private
    def set_current_account
      Current.account = current_user.account
    end
end

Scoping queries with default_scope (and why you should not)

The tempting approach is default_scope { where(account: Current.account) }. Do not do this. Default scopes are invisible, they leak into associations in unexpected ways, they break unscoped queries, and they make debugging a nightmare.

Instead, use explicit scoping through your associations or a dedicated gem.

acts_as_tenant: the practical choice

The acts_as_tenant gem has been the standard row-level tenancy solution in Rails for years, and for good reason. It automatically scopes queries, validates tenant presence on create, and prevents cross-tenant access -- without the footguns of default_scope.

# Gemfile
gem "acts_as_tenant"

# app/models/project.rb
class Project < ApplicationRecord
  acts_as_tenant :account
  # All queries now automatically scoped to Current.account
  # Project.all only returns current tenant's projects
  # Project.create automatically sets account_id
end

# app/controllers/application_controller.rb
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

What acts_as_tenant gives you that hand-rolled scoping does not:

  • Automatic query scoping. Every WHERE clause includes the tenant filter. You cannot accidentally forget it.
  • Validation on create. Records are automatically assigned to the current tenant. No risk of creating orphaned records.
  • Cross-tenant protection. Attempting to access a record from another tenant raises an error instead of silently returning nil or wrong data.
  • Subdomain and domain support. Built-in strategies for identifying tenants from the request URL.

When row-level falls short

Row-level tenancy has real limitations:

  • No database-level isolation. A bug in your application code can expose tenant data. The database does not enforce boundaries.
  • Noisy neighbor problems. One tenant running expensive queries affects everyone. You need application-level rate limiting and query monitoring.
  • Compliance gaps. Some regulations (healthcare, finance, government) require data to be physically separated. Row-level tenancy may not satisfy auditors.
  • Backup granularity. You cannot back up or restore a single tenant without dumping the entire database and filtering.

Schema-per-tenant: when compliance demands it

Schema-based tenancy uses PostgreSQL schemas (or MySQL databases) to give each tenant their own set of tables within a shared database server. The apartment gem was the original Rails solution; its successor ros-apartment maintains it for modern Rails.

# Gemfile
gem "ros-apartment", require: "apartment"

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

# Switching tenants
Apartment::Tenant.switch("acme") do
  # All queries hit the "acme" schema
  Project.all  # SELECT * FROM acme.projects
end

The appeal

  • Database-level isolation. Tenants literally cannot see each other's tables. No amount of application bugs will leak data across schemas.
  • Per-tenant backups. You can dump and restore individual schemas without touching other tenants.
  • Schema customization. In theory, you can add tenant-specific columns or tables. In practice, you rarely should.
  • Auditor-friendly. It is easy to demonstrate data isolation when each tenant has separate tables.

The pain

  • Migrations multiply. Every migration runs once per tenant. 100 tenants means 100x migration time. This gets painful fast.
  • Connection pooling breaks. Schema switching interacts poorly with connection pools. You need to be careful about how and when you switch schemas, especially in background jobs.
  • Cross-tenant queries are expensive. Reporting, analytics, or admin dashboards that need data from all tenants become complex multi-schema joins or require a separate denormalized store.
  • Gem maintenance risk. The apartment gem has had periods of neglect. You are coupling a core infrastructure concern to a community-maintained gem.
  • SQLite incompatible. This pattern is PostgreSQL-only (or MySQL with separate databases). If you want Rails 8 defaults with SQLite, schema-per-tenant is off the table.

Schema-per-tenant made sense in 2018 when row-level tooling was immature. In 2026, the operational overhead rarely justifies the isolation benefits unless you are in healthcare, finance, or government -- and even then, database-per-tenant might be the better call.

Database-per-tenant: maximum isolation

Each tenant gets their own database. Complete physical isolation. This is what enterprise customers ask for when they say "our data cannot touch anyone else's."

Historically, this pattern was operationally brutal in Rails. Managing hundreds of database connections, running migrations across all of them, handling connection pooling -- it required significant custom infrastructure.

Rails 8 changes the math here, especially with SQLite.

Rails 8 multi-database support

Rails has supported multiple databases since version 6, but it was designed for read replicas and functional partitioning (separate databases for different concerns). With Rails 8 and mature SQLite support, database-per-tenant becomes genuinely practical for certain workloads.

# config/database.yml
production:
  primary:
    adapter: sqlite3
    database: storage/production.sqlite3

# Dynamically connect to tenant databases
# app/models/concerns/tenant_database.rb
module TenantDatabase
  extend ActiveSupport::Concern

  included do
    connects_to database: { writing: :primary }
  end

  class_methods do
    def connect_to_tenant(tenant)
      connected_to(database: {
        adapter: "sqlite3",
        database: "storage/tenants/#{tenant.database_name}.sqlite3"
      }) do
        yield
      end
    end
  end
end

SQLite makes this pattern viable for indie hackers

Here is what changed: with PostgreSQL, database-per-tenant meant managing connection pools, separate server resources, and complex provisioning. With SQLite, a new tenant database is just a new file on disk.

  • Provisioning is instant. Creating a tenant means creating a file and running migrations. No connection pool reconfiguration, no server provisioning.
  • Backups are file copies. cp storage/tenants/acme.sqlite3 backups/ -- that is your per-tenant backup.
  • Tenant deletion is rm. No data cleanup queries, no cascade concerns. Delete the file.
  • Resource isolation is real. Each database has its own WAL, its own lock. One tenant's heavy write load does not block another.

The trade-off is scale ceiling. SQLite on a single server maxes out well before PostgreSQL on dedicated hardware. But for a SaaS with hundreds of tenants doing moderate workloads, it is a legitimate architecture.

When database-per-tenant makes sense

  • Data residency requirements. Tenant data must live in specific geographic regions. Separate databases can live on separate servers.
  • Enterprise sales. Large customers who contractually require physical data isolation.
  • Extreme compliance. HIPAA, SOC 2 Type II, or financial regulations where auditors want to see physical separation.
  • Tenant-specific scaling. One tenant is 100x the size of others and needs dedicated resources.

The pitfall that matters most: data leaks

Multi-tenant data leaks are the kind of bug that ends companies. Not "oops, the page rendered wrong" -- more like "Customer A can see Customer B's financial data and now we are on Hacker News for the wrong reasons."

With row-level tenancy, every data leak is an application bug. The database does not protect you. Here are the most common ways it happens.

Missing tenant scope in queries

The classic. Someone writes a query that forgets the tenant filter:

# DANGEROUS: returns ALL projects across ALL tenants
Project.where(status: "active")

# SAFE: scoped to current tenant
Current.account.projects.where(status: "active")

This is why acts_as_tenant exists. It makes forgetting the scope impossible because the scope is applied automatically.

Background jobs losing tenant context

Current attributes are reset between requests. Background jobs run in a different context. If your job does not explicitly set the tenant, it runs unscoped.

# DANGEROUS: tenant context is lost
class ReportJob < ApplicationJob
  def perform(project_id)
    project = Project.find(project_id)  # Finds across ALL tenants
    project.generate_report
  end
end

# SAFE: explicitly set tenant context
class ReportJob < ApplicationJob
  def perform(account_id, project_id)
    account = Account.find(account_id)
    ActsAsTenant.with_tenant(account) do
      project = Project.find(project_id)
      project.generate_report
    end
  end
end

Association traversal bypassing scopes

Even with tenant scoping on your primary model, associated records might not be scoped:

# If Comment belongs_to :project but does not have acts_as_tenant
# This could return comments from other tenants
project.comments

# Every model that holds tenant data needs the tenant scope
class Comment < ApplicationRecord
  acts_as_tenant :account
  belongs_to :project
end

Admin and API endpoints

Admin panels and API endpoints are the most common places where tenant scoping gets "temporarily" disabled and then forgotten. Every admin query and API endpoint needs the same tenant discipline as your user-facing code.

Testing tenant isolation

If you are not testing tenant isolation, you do not have tenant isolation. You have tenant isolation until the next deploy.

The minimum test suite

Every tenant-scoped model needs at least these tests:

class ProjectTenantIsolationTest < ActiveSupport::TestCase
  setup do
    @account_a = accounts(:acme)
    @account_b = accounts(:globex)
    @project_a = projects(:acme_project)
    @project_b = projects(:globex_project)
  end

  test "queries only return current tenant records" do
    ActsAsTenant.with_tenant(@account_a) do
      assert_includes Project.all, @project_a
      assert_not_includes Project.all, @project_b
    end
  end

  test "cannot find records from another tenant" do
    ActsAsTenant.with_tenant(@account_a) do
      assert_raises(ActiveRecord::RecordNotFound) do
        Project.find(@project_b.id)
      end
    end
  end

  test "creates records with current tenant" do
    ActsAsTenant.with_tenant(@account_a) do
      project = Project.create!(name: "New Project")
      assert_equal @account_a, project.account
    end
  end

  test "prevents updating record to different tenant" do
    ActsAsTenant.with_tenant(@account_a) do
      assert_raises(ActsAsTenant::Errors::TenantIsImmutable) do
        @project_a.update!(account: @account_b)
      end
    end
  end
end

Integration tests for background jobs

Test that your jobs maintain tenant context through the entire execution:

class ReportJobTenantTest < ActiveSupport::TestCase
  test "job executes within correct tenant context" do
    account = accounts(:acme)
    project = projects(:acme_project)

    # Verify the job does not leak data
    assert_nothing_raised do
      ReportJob.perform_now(account.id, project.id)
    end

    # Verify the report belongs to the correct tenant
    ActsAsTenant.with_tenant(account) do
      report = project.reports.last
      assert_equal account, report.account
    end
  end
end

CI enforcement

Add a test helper that catches unscoped queries in development and CI:

# test/test_helper.rb
ActsAsTenant.configure do |config|
  config.require_tenant = true  # Raises if no tenant is set
end

With require_tenant = true, any query on a tenant-scoped model without a tenant set will raise an error. This catches missing scopes in tests before they reach production.

Migration strategies: from shared to isolated

Most SaaS apps start with row-level tenancy and some eventually need to migrate specific tenants (usually the largest or most compliance-sensitive) to their own databases. Here is how to plan for that transition.

Design for row-level, prepare for isolation

  • Use a consistent tenant foreign key. Pick account_id and put it on every tenant-scoped table. Do not mix team_id, org_id, and tenant_id across different models.
  • Avoid cross-tenant joins. If your reporting dashboard needs data from all tenants, build it as a separate read path from the start. Do not bake cross-tenant queries into your application models.
  • Keep tenant-specific data separate from global data. Configuration, feature flags, and billing live in global tables. Business data lives in tenant-scoped tables. Draw this line early.
  • Use UUIDs for primary keys. If you ever need to merge or migrate tenant data, auto-incrementing IDs will collide. UUIDs do not.

The progressive isolation pattern

When a tenant outgrows shared infrastructure:

  • Export the tenant's data to a new database (SQLite file or PostgreSQL instance)
  • Update the tenant record with a connection identifier pointing to the new database
  • Add a connection switcher in your middleware that routes requests based on the tenant's database configuration
  • Remove the tenant's data from the shared database

This works cleanly when every query goes through the tenant scope. If you have unscoped queries scattered through the codebase, migration becomes a search-and-fix-every-query exercise.

Performance considerations

Row-level: index everything

With shared tables, every query includes a tenant filter. That filter needs an index:

# Composite indexes for tenant-scoped queries
class AddTenantIndexes < ActiveRecord::Migration[8.0]
  def change
    add_index :projects, [:account_id, :status]
    add_index :projects, [:account_id, :created_at]
    add_index :comments, [:account_id, :project_id]
  end
end

Every query pattern that includes the tenant column needs a composite index where account_id is the leading column. Without this, the database scans the entire table and filters in memory. At scale, that is the difference between 2ms and 2 seconds.

Schema-per-tenant: watch migration time

At 500 tenants, a migration that takes 1 second per schema takes over 8 minutes total. At 5,000 tenants, you are looking at over an hour of migration time. Plan for background migrations and rolling deploys.

Database-per-tenant: connection management

With PostgreSQL, each tenant database consumes connection pool resources. At hundreds of tenants, you need connection poolers like PgBouncer and careful pool size tuning. With SQLite, this is a non-issue -- connections are lightweight and file-based.

How Omaship handles tenant isolation

Omaship ships with row-level tenancy as the default because it is the right starting point for 90% of SaaS applications. Here is what you get out of the box:

  • Account model as the tenant boundary. Every user belongs to an account. Every business model is scoped to an account. The association is enforced at the model layer.
  • Current attributes for request context. Current.account is set early in every request. Controllers, models, and views all have access to the tenant context without passing it around.
  • Background job tenant propagation. Jobs that operate on tenant data receive the account context explicitly. No implicit state that gets lost between the web process and the job worker.
  • Test helpers for isolation verification. The test suite includes patterns for verifying that tenant boundaries hold under various conditions.
  • Composite indexes on tenant-scoped tables. Database indexes are set up for efficient tenant-filtered queries from the start.

The architecture is designed so that upgrading to schema-based or database-per-tenant isolation later does not require rewriting your application logic. The tenant scope is consistent across the codebase, making the migration path straightforward when the time comes.

The decision framework

Stop overthinking this. Here is the decision tree:

  • Are you pre-revenue or under $500K ARR? Use row-level tenancy. acts_as_tenant plus Current attributes. Ship the product. You can always migrate later, and you probably will not need to.
  • Do you have contractual data isolation requirements from enterprise customers? Use database-per-tenant for those specific customers, row-level for everyone else. Rails multi-database support makes this hybrid approach viable.
  • Are you in healthcare, finance, or government with strict compliance audits? Talk to your compliance team first. They may accept row-level with proper access controls and audit logging. If not, database-per-tenant gives you the clearest isolation story.
  • Are you building on SQLite for operational simplicity? Row-level is the default. If you need per-tenant isolation, database-per-tenant with separate SQLite files is elegant and simple. Schema-per-tenant is not an option with SQLite.

The best multi-tenancy architecture is the one that lets you ship features instead of debugging infrastructure. For most Rails SaaS apps in 2026, that is row-level tenancy with acts_as_tenant, solid test coverage, and a codebase clean enough to migrate later if you need to.

Start simple. Test your boundaries. Ship the product. The tenancy pattern matters far less than having paying customers who need you to figure it out.

Multi-tenancy that works from day one

Omaship gives you tenant isolation, scoped queries, background job context propagation, and the test patterns to verify it all. Start building your multi-tenant SaaS on a foundation that scales.

Want more guides?

Join the waitlist and we'll send new guides straight to your inbox.

No credit card. We send the free template links and 4 practical follow-up emails.

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy