Database sharding: the strategies that work and the ones that cause regret
I have seen teams adopt sharding as the solution to performance problems that could have been solved with better indexes, read replicas, or caching. Sharding is a last resort that adds enormous operational complexity. But when you genuinely need it, the strategy you choose locks you in. Here is what each strategy looks like in practice and when to choose each.
Before sharding: exhaust these first
Optimization order (simplest to most complex): 1. Query optimization + indexes (free, immediate) 2. Read replicas (1 week to add) 3. Connection pooling (PgBouncer) (1 day to add) 4. Caching layer (Redis) (1 week to add) 5. Table partitioning (1-2 days) 6. Vertical scaling (larger DB) (hours) 7. Horizontal sharding (months + ongoing cost)
Hash sharding
import hashlib
from typing import Any
class HashShardRouter:
def __init__(self, shard_connections: list):
self.shards = shard_connections
self.shard_count = len(shard_connections)
def get_shard(self, shard_key: str) -> Any:
"""Deterministically route to a shard based on the key."""
hash_value = int(hashlib.md5(shard_key.encode()).hexdigest(), 16)
shard_index = hash_value % self.shard_count
return self.shards[shard_index]
async def execute(self, shard_key: str, query: str, params: list):
conn = self.get_shard(shard_key)
return await conn.fetch(query, *params)
async def execute_all_shards(self, query: str, params: list):
"""For queries without a shard key — fan out to all shards."""
results = []
for shard in self.shards:
rows = await shard.fetch(query, *params)
results.extend(rows)
return results
router = HashShardRouter([shard0_pool, shard1_pool, shard2_pool, shard3_pool])
# All user's data goes to the same shard
await router.execute(user_id, "SELECT * FROM orders WHERE user_id = $1", [user_id])
# Cross-user queries hit all shards
total_revenue = await router.execute_all_shards(
"SELECT SUM(amount) FROM orders WHERE date > $1", [start_date]
)
Range sharding
from datetime import datetime
from typing import Any
class RangeShardRouter:
"""
Route based on a range key (e.g., created_at date).
Good for time-series data where you query by date range.
Risk: hotspots — the latest shard gets all writes.
"""
def __init__(self, shard_ranges: list[tuple[datetime, datetime, Any]]):
# [(start_date, end_date, connection)]
self.ranges = shard_ranges
def get_shard(self, date: datetime) -> Any:
for start, end, conn in self.ranges:
if start <= date < end:
return conn
raise ValueError(f"No shard for date: {date}")
def get_shards_for_range(
self,
start: datetime,
end: datetime
) -> list[Any]:
"""Return all shards that overlap with the given date range."""
return [
conn for range_start, range_end, conn in self.ranges
if range_start < end and range_end > start
]
Tenant-based sharding (most practical for SaaS)
class TenantShardRouter:
"""
Route by tenant ID — each tenant's data in one shard.
Enables tenant-level isolation and easy data export/migration.
Directory stored in a central 'routing' database.
"""
def __init__(self, routing_db, shard_pools: dict[str, Any]):
self.routing_db = routing_db
self.shards = shard_pools
self._cache: dict[str, str] = {} # tenant_id -> shard_id
async def get_shard(self, tenant_id: str) -> Any:
if tenant_id not in self._cache:
row = await self.routing_db.fetchrow(
"SELECT shard_id FROM tenant_routing WHERE tenant_id = $1",
tenant_id
)
if not row:
# Assign to least loaded shard
shard_id = await self.assign_tenant(tenant_id)
else:
shard_id = row["shard_id"]
self._cache[tenant_id] = shard_id
return self.shards[self._cache[tenant_id]]
async def assign_tenant(self, tenant_id: str) -> str:
"""Assign new tenant to the shard with fewest tenants."""
row = await self.routing_db.fetchrow("""
SELECT shard_id, COUNT(*) as tenant_count
FROM tenant_routing
GROUP BY shard_id
ORDER BY tenant_count ASC
LIMIT 1
""")
shard_id = row["shard_id"] if row else "shard-0"
await self.routing_db.execute(
"INSERT INTO tenant_routing (tenant_id, shard_id) VALUES ($1, $2)",
tenant_id, shard_id
)
return shard_id
The cross-shard query problem
The biggest operational cost of sharding is cross-shard queries. Any query that does not include the shard key must fan out to all shards:
async def count_all_users(router: HashShardRouter) -> int:
"""Fan-out: must query all shards and sum results."""
counts = await asyncio.gather(*[
shard.fetchval("SELECT COUNT(*) FROM users")
for shard in router.shards
])
return sum(counts)
async def find_user_by_email(email: str, router: HashShardRouter) -> dict | None:
"""Must check all shards — email is not the shard key."""
results = await asyncio.gather(*[
shard.fetchrow("SELECT * FROM users WHERE email = $1", email)
for shard in router.shards
], return_exceptions=True)
for result in results:
if result and not isinstance(result, Exception):
return dict(result)
return None
If you find yourself writing many cross-shard queries, you probably chose the wrong shard key — or sharding is the wrong solution. Sharding works best when 90%+ of queries include the shard key. If your most common queries cannot be routed to a single shard, the complexity cost is not worth it.