Database Sharding in Rails: Implementing Multi-Database Architectures
There’s a point in every growing service where the primary database becomes the bottleneck, and it’s not about reads. Read replicas solved reads. The pain is writes: one node, one write path, and every new customer adds to the same contention. Vertical scaling works until it doesn’t — the instance doubles in price for a fraction of the headroom, and the ceiling is the hardware, not your code. That’s when horizontal sharding stops being a theoretical exercise.
Rails 6.1 (December 2020) shipped native support for this, and for the first time you can do it without a gem like Octopus or a hand-rolled connection pool: multiple databases plus horizontal shards, all configured in database.yml and routed through connects_to and connected_to.
Declaring the shards
The split key for us is the tenant (account). Reference data and the accounts directory stay on the primary; the write-heavy transactional tables live on shards. The database layer looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/database.yml
production:
primary:
adapter: postgresql
database: app_primary
host: primary.example.com
primary_shard_one:
adapter: postgresql
database: app_shard_1
host: shard1.example.com
primary_shard_two:
adapter: postgresql
database: app_shard_2
host: shard2.example.com
1
2
3
4
5
6
7
8
9
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to shards: {
default: { writing: :primary },
shard_one: { writing: :primary_shard_one },
shard_two: { writing: :primary_shard_two }
}
end
Tables that must never be sharded — accounts, plans, migrations state — subclass a plain abstract class that connects only to primary, so connects_to doesn’t drag them along. Mixing sharded and unsharded tables in one app is normal and desirable; the shard switch only applies to the models that opted in.
Routing per request
The request tells you the tenant, and the tenant maps to a shard. Do the mapping in middleware so controllers never see it:
1
2
3
4
5
6
7
8
9
class ShardRoutingMiddleware
def call(env)
tenant_id = env.fetch("HTTP_X_TENANT_ID", "default")
shard = ShardRouter.shard_for(tenant_id)
ActiveRecord::Base.connected_to(role: :writing, shard: shard) do
@app.call(env)
end
end
end
1
2
3
4
5
6
7
8
class ShardRouter
SHARDS = %i[shard_one shard_two].freeze
def self.shard_for(tenant_id)
return :default if tenant_id == "default"
SHARDS[Zlib.crc32(tenant_id.to_s) % SHARDS.length]
end
end
The hash function matters. It must be stable forever: the day you change it, every tenant’s data is in the “wrong” shard and lookups break. Hash the tenant’s stable identifier (subdomain or UUID), never the row’s auto-increment id, and treat the mapping as a permanent contract. We also keep a tiny tenant → shard directory table on the primary so background jobs and dead-letter reprocessing can resolve the right shard idempotently instead of re-deriving it.
IDs that can’t collide
The moment you have two shards, auto-increment primary keys collide. Use UUIDs for sharded tables from day one:
1
2
3
4
5
6
7
8
9
class CreateOrders < ActiveRecord::Migration[6.1]
def change
create_table :orders, id: :uuid do |t|
t.belongs_to :account, type: :uuid
t.decimal :total_cents, precision: 12, scale: 0, default: 0
t.timestamps
end
end
end
Postgres generates these with gen_random_uuid() (built in on 13+, or via the pgcrypto extension earlier), so there’s no application coordination and no counter. Snowflake-style 64-bit IDs work too and keep the indexes smaller, but they add a generator service and a clock dependency; UUIDs are the boring, correct default.
What you give up
Sharding isn’t free, and the invoice arrives as three hard limits:
- No cross-shard joins. The query planner can’t see across nodes. Anything that needs data from two shards gets fanned out in the application — N queries, and the latency of the slowest one. We profile any query touching more than one shard as a bug candidate and keep aggregate/reporting data denormalized on the primary.
- No cross-shard transactions. A write spanning two shards is two transactions, which means it can partially fail. Keep transaction boundaries inside a single shard, and design the data model so money movement is single-shard or moves through a reconcilable queue on the primary.
- No global uniqueness. A unique index is per-shard. The only things that must be globally unique (email addresses, customer-facing reference codes) get a global index on the primary — an
accountsdirectory row created before the sharded record — plus the inevitable retry-on-duplicate handling.
Production lessons
- Pick the shard key by write amplification, not read pattern. Re-keying after launch is a full data migration with a rehearsal, not a config change.
- Freeze the hash. Add a migration-level check that a tenant’s shard mapping is stable across deploys; the worst bugs we’ve had were shards where “this record went missing” because a mapping changed.
- Shard count is hard to grow. We sized for two and would rather re-shard by doubling than add a third; plan your capacity for the shard count being fixed for a long time.
- Watch per-shard skew. Alerts on write latency and size per shard catch a hot tenant before it pins one node.
- Practice the failover.
connected_to(role: :writing, shard: X)scoping is exactly where a missingconnects_tosilently sends data to the wrong database, and it only takes one.
Sharding is the last database lever you pull, and Rails 6.1 made it an order of magnitude more approachable: routing, connections, and scoping are framework features now, not hacks. The hard part was never the code — it’s respecting that the data lives in N places forever.