Services About Us Why Choose Us Our Team Development Workflow Technology Stack Case Studies Portfolio Blog Free Guides Shopify Audit ($499) Estimate Project Contact Us
← Back to Blog

Rails Multi-Tenancy: Row-Level vs Schema vs Database (Production Decision Framework)

The three Rails multi-tenancy patterns look interchangeable in tutorials. They're not. Pick row-level with acts_as_tenant for 95% of SaaS builds, schema-per-tenant only for compliance, and database-per-tenant only on Rails 8 + SQLite. Here's the framework, the production gotchas, and the migration paths between them.

TV
TechVinta Team May 18, 2026 Full-stack development agency specializing in Rails, React, Shopify & Sharetribe
Rails Multi-Tenancy: Row-Level vs Schema vs Database (Production Decision Framework)

The short answer

For most Rails SaaS builds in 2026, use row-level multi-tenancy with acts_as_tenant. It's the simplest model, scales to millions of rows per tenant on modest hardware, and doesn't trap you in schema-migration purgatory. Reach for schema-per-tenant only if compliance demands strict isolation. Reach for database-per-tenant only on Rails 8 with SQLite using 37signals' ActiveRecord::Tenanted.

We've shipped all three. The most production-tested Apartment-gem deployment we've built is the multi-tenant Rails dashboard powering ZenHQ's grocery delivery platform — multiple tenants, custom checkout, Paysafe 3DS, the works. It works. We also wouldn't choose schema-per-tenant for a greenfield SaaS today. The reasons live in the gotchas section below.

Watch first: Mike Dalessio on how 37signals does multi-tenancy

Before picking an approach, watch the team that ships Basecamp and HEY explain why they went all-in on database-per-tenant with SQLite on Rails 8. Mike Dalessio walks through the trade-offs they considered and rejected. It's the best 30 minutes you can spend before this decision.

The three approaches at a glance

Every Rails multi-tenancy article opens with the same table. Here's ours — with the production-relevant column nobody else includes (operational pain).

Approach Isolation Migration cost Operational pain Default choice when
Row-level (acts_as_tenant) Logical (query-scoped) Cheap — one schema Low. Indexes need a tenant_id column. Greenfield SaaS, < 10K tenants, no hard isolation rules
Schema-per-tenant (Apartment) Strong (separate schemas) Expensive — N schemas per migration High at scale. Migrations + connection pool issues. Compliance demands schema isolation (HIPAA-adjacent)
Database-per-tenant (Tenanted) Strongest (separate DBs) Medium — orchestration required Medium. SQLite makes it tractable. Rails 8 + SQLite-per-tenant playbook (37signals model)

The biggest 2026 shift: the Apartment gem's authors no longer recommend schema-per-tenant for new builds, and Heroku explicitly warns against it. The apartment repo README still maintains the gem because thousands of production apps depend on it — but it's not the right starting point anymore.

The 4-question decision framework

Don't guess. Answer these and the choice is forced.

1. Do you have hard data-isolation requirements?

By "hard" we mean: a customer contract, a compliance regime (HIPAA, FedRAMP, certain financial-services rules), or a security policy that requires tenant data to be physically separated — not just logically scoped by a foreign key.

If yes: row-level is off the table. You're in schema-per-tenant or database-per-tenant territory. Most "we need isolation" claims don't actually require this — row-level with proper scoping satisfies most enterprise auditors. But if your customer's procurement contract literally says "physically separated databases," believe them.

If no: row-level wins on every other axis. Move to question 2 only if you're curious.

2. How many tenants do you expect in 24 months?

Under 1,000: any approach works. Pick row-level because it's the cheapest to maintain.

1,000–10,000: row-level still works, but you'll need to think about indexes carefully (every index includes tenant_id as the leading column) and your largest tables will need partitioning. Schema-per-tenant becomes painful here — 10,000 schemas means 10,000 sets of tables, and most monitoring tools break.

Over 10,000: reconsider the whole architecture. At this scale you're either (a) wrong about your business model and should be on row-level with sharding, or (b) you're 37signals-scale and should follow the database-per-tenant SQLite playbook.

3. How heterogeneous is tenant data shape?

If every tenant has the same schema (typical SaaS — same product, different customers), row-level wins. If tenants need structurally different tables (per-tenant custom fields beyond what JSON columns handle), schema-per-tenant earns a second look. But before you go there, try a jsonb column with proper indexes. Postgres handles per-tenant flexibility in row-level far better than people think.

4. Are you on Rails 8 with SQLite already?

If yes, the database-per-tenant path is genuinely viable for the first time in Rails history. The 37signals talk above explains why — SQLite-per-tenant gives you complete isolation, trivial backups (just copy the file), no cross-tenant query risk, and zero connection-pool contention. It's a genuine architectural option, not just an academic one.

If you're on Postgres + Rails 7 or earlier, database-per-tenant is operationally heavy. Skip it.

Production gotchas per approach

Row-level gotchas

The "what could go wrong with row-level" answer is short and brutal: one missed default_scope and a tenant sees another tenant's data. That's the entire risk surface. Mitigations:

  • Use acts_as_tenant religiously. The acts_as_tenant gem auto-scopes queries, validates tenant presence on create, and raises on cross-tenant access. Don't roll your own.
  • Every index leads with tenant_id. A composite index on (tenant_id, created_at) is fast. An index on just (created_at) forces a full table scan because Postgres can't prune to one tenant.
  • Test the negative path. Write a request spec that signs in as tenant A and tries to access tenant B's resources. It should 404. If it returns the resource, you have a leak somewhere — usually a missed scope on a Service Object or a controller action that bypasses the standard authorization flow.
  • Background jobs need tenant context. Sidekiq workers don't have a current_tenant by default. Pass tenant_id explicitly in the job arguments, then call ActsAsTenant.with_tenant(tenant) { ... } in perform. Forgetting this is the single most common production data-leak source.

For broader query-shape patterns, our Rails ActiveRecord best practices guide covers the indexing patterns that make row-level fast at scale.

Schema-per-tenant gotchas (the Apartment-gem reality)

This is the section we wish someone had written before we shipped our first multi-tenant Apartment-gem app.

  • Migrations become an O(N) problem. Adding a column means running ALTER TABLE across N schemas. At 200 tenants, that's manageable. At 2,000, it's a 30-minute deploy. At 10,000, it's an overnight job and you start splitting migrations into "fast" and "slow" buckets.
  • Postgres connection limits hit early. Apartment switches schemas via SET search_path, which is per-connection. Connection pools don't share search_path state, so PgBouncer in transaction mode breaks Apartment. You're stuck in session mode, which caps you on max connections faster than you'd expect.
  • Autovacuum falls behind on tenant-heavy tables. Each tenant's schema has its own tables, but autovacuum tunes globally. Some tenants are 100K rows, some are 100M. Autovacuum's default heuristics don't handle this gracefully. You'll spend time tuning autovacuum_vacuum_scale_factor per table.
  • Cross-tenant queries are painful. Want a single dashboard showing your top 10 tenants by revenue? In row-level, that's one query. In schema-per-tenant, that's N queries (one per schema) or a materialised view you have to maintain.
  • Backups are bigger and slower. A single Postgres dump now contains N schemas. Restoring a single tenant from backup means restoring the whole dump and dropping the schemas you don't need.

None of these are dealbreakers individually. Combined, they explain why the Apartment maintainers themselves no longer recommend the approach for new projects. The Postgres schema docs are worth reading if you're still considering this path — they make the operational model explicit in ways that change the calculus.

Database-per-tenant gotchas

Operationally heaviest, but the gotchas are different in kind:

  • Provisioning a tenant means provisioning a database. Either you keep an empty database in a pool (fast tenant creation) or you create databases on-demand (slow first-request).
  • Schema migrations multiply by tenant count. Same pain as schema-per-tenant, but applied to actual databases. SQLite-per-tenant on Rails 8 mitigates this because SQLite ALTER TABLE is fast and atomic.
  • Connection pool sizing is N-dependent. 10K tenants × 1 connection each is 10K connections you may or may not actually need. You need a router that opens connections on-demand and recycles aggressively.
  • Cross-tenant analytics are basically impossible at the database layer. You're shipping events to a separate analytics store. Plan for it.

Migration paths between the three

The hardest version of this conversation is "we picked the wrong one, can we move?" Yes, but the cost depends on direction.

Schema-per-tenant → row-level (most common)

This is the path most Apartment-gem-fatigued teams walk. It's painful but bounded:

  1. Add a tenant_id column to every table (nullable initially).
  2. Run a backfill: for each schema, read all rows, insert into the public schema's table with the tenant_id set.
  3. Switch reads to the public schema with default scope on tenant_id.
  4. Switch writes (dual-write briefly, then cut over).
  5. Drop the per-tenant schemas.

Allow 3–5 weeks for a mid-sized app (50–500 tenants). The risk is data-loss during backfill, so write the backfill idempotently and run it twice as a verification step.

Row-level → schema-per-tenant or database-per-tenant

Reverse migration. Rarer, but happens when compliance lands as a late requirement. Cost is roughly 2x the forward migration because you're splitting a known-good unified dataset into N pieces, and each split needs verification.

Schema-per-tenant → database-per-tenant

If you're already isolated at the schema level, moving to database-per-tenant is a logical extension. The connection-routing logic mostly carries over. You're trading "schemas in one database" for "databases on one cluster," which simplifies some things (per-database backup) and complicates others (cross-tenant queries become network calls).

Where this fits in a Rails SaaS architecture

Multi-tenancy is one of the first irreversible architectural decisions in a SaaS build. It shapes your auth model, your background job structure, your billing logic, and your data warehouse. Our Rails SaaS development guide covers the surrounding decisions — subscription billing, tenant onboarding, role-based auth — that interact with whichever multi-tenancy model you pick.

The cost side of these decisions matters too. Schema-per-tenant adds DBA hours you'll pay for forever; row-level keeps you closer to standard Rails operations cost. Our SaaS development cost guide walks through the line items where multi-tenancy choices show up.

FAQ: Rails multi-tenancy decisions

Should I still use the Apartment gem in 2026?
For new projects, no. The gem still works for the apps that depend on it, but the maintainers don't recommend it for greenfield builds. Use acts_as_tenant for row-level multi-tenancy. Only consider Apartment if you have a specific reason to want PostgreSQL schemas (usually compliance).

What's the difference between row-level and column-level multi-tenancy?
Same thing in most discussions. Both mean tenants share tables and rows are distinguished by a tenant column. Some authors split hairs but the implementation is identical.

Can I mix multi-tenancy approaches in one app?
Yes but rarely a good idea. The common case is row-level for most data plus database-per-tenant for one large isolated dataset (e.g. document storage). The operational complexity doubles, so only do this if you've measured a specific problem the hybrid solves.

How do I handle subdomains with row-level multi-tenancy?
A before_action middleware that resolves the subdomain to a Tenant record and sets it as the current tenant for the request. acts_as_tenant has a generator for this. The actual data scoping is independent of how you resolve the tenant — subdomains, path prefixes, JWT claims, all work the same way underneath.

What about ActiveRecord::Tenanted from 37signals?
It's specifically for the database-per-tenant pattern on Rails 8 + SQLite. If you're building a new Rails 8 SaaS and willing to commit to SQLite, it's the cleanest implementation available. If you're on Postgres or earlier Rails, it doesn't apply — stick with row-level via acts_as_tenant.

How we can help

At TechVinta, we've shipped Rails multi-tenant SaaS apps across all three patterns — including the multi-tenant Apartment-gem deployment behind ZenHQ's grocery delivery platform and several greenfield row-level builds for B2B SaaS clients. We've also migrated apps off the Apartment gem to row-level after schema-migration pain hit, and we know where the bodies are buried.

Planning a Rails SaaS build and trying to lock in the multi-tenancy choice before you regret it? Or running a schema-per-tenant app that's getting painful to deploy? Talk to our SaaS engineering team, or get a free project estimate — we'll review your tenant scale, isolation requirements, and migration appetite and propose a plan within 48 hours.

Share this article:
TV

Written by TechVinta Team

We are a full-stack development agency specializing in Ruby on Rails, React.js, Vue.js, Flutter, Shopify, and Sharetribe. We write about web development, DevOps, and building scalable applications.

Keep Reading

TechVinta Assistant

Online - Ready to help

Hi there!

Need help with your project? We're online and ready to assist.

🍪

We use cookies for analytics to improve your experience. See our Cookie Policy.