From harness-claude
Guides horizontal sharding to scale PostgreSQL beyond single-instance limits for high write volumes, multi-tenant apps, or >10TB data, covering shard key selection, routing, and cross-shard queries.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Distributing rows of a table across multiple database instances (shards) to scale beyond the capacity of a single server, with careful attention to shard key selection and cross-shard query complexity.
Implements database sharding strategies like range-based, hash-based, and directory-based for horizontal scalability across multiple servers. Use when databases exceed single-server capacity or need distributed data isolation.
Implements horizontal sharding for PostgreSQL, MySQL, MongoDB including shard key selection, data distribution analysis, query routing, and rebalancing.
Implements PostgreSQL/MySQL sharding with hash, range, directory strategies including routers and shard key selection. Use for horizontal scaling, multi-tenant isolation, billions of records, hotspots, rebalancing, cross-shard queries.
Share bugs, ideas, or general feedback.
Distributing rows of a table across multiple database instances (shards) to scale beyond the capacity of a single server, with careful attention to shard key selection and cross-shard query complexity.
1. What Is Horizontal Sharding
Horizontal sharding splits a table's rows across multiple independent database instances. Each shard holds a subset of the data. The application or a routing layer determines which shard to query based on the shard key.
Unsharded: [All orders in one database]
Sharded: Shard 0: orders where tenant_id % 4 = 0
Shard 1: orders where tenant_id % 4 = 1
Shard 2: orders where tenant_id % 4 = 2
Shard 3: orders where tenant_id % 4 = 3
2. Shard Key Selection
The shard key determines data distribution. A good shard key:
Good shard keys:
tenant_id -- multi-tenant SaaS (queries always filter by tenant)
user_id -- social platforms (queries center on a user)
region -- geographically distributed systems
Bad shard keys:
created_at -- all new writes go to the latest shard (hot shard)
status -- highly skewed: most rows are 'active'
email -- rarely used as a query filter
3. Shard Routing
# Hash-based routing (application layer)
def get_shard(tenant_id: int, num_shards: int) -> int:
return tenant_id % num_shards
# Lookup-table routing (more flexible, supports resharding)
# shard_map table: tenant_id -> shard_id
def get_shard(tenant_id: int) -> int:
return db.query("SELECT shard_id FROM shard_map WHERE tenant_id = %s", tenant_id)
4. Cross-Shard Queries
Queries that do not include the shard key must fan out to all shards:
-- Shard-local (fast): shard key in WHERE
SELECT * FROM orders WHERE tenant_id = 42 AND status = 'pending';
-- Cross-shard (slow): no shard key, must query all shards
SELECT count(*) FROM orders WHERE status = 'pending';
-- The routing layer sends this to every shard and aggregates results
Minimize cross-shard queries by colocating related data on the same shard.
5. Consistent Hashing
Simple modulo hashing (tenant_id % N) requires rehashing all data when adding a shard. Consistent hashing maps keys to a ring, so adding a shard only moves keys from adjacent positions:
Hash ring: 0 -------- 1 -------- 2 -------- 3 -------- 0
| Shard A | Shard B | Shard C | Shard A |
Adding Shard D between B and C:
| Shard A | Shard B | D | Shard C | Shard A |
-- Only keys between B and D move; all other keys stay put
Scenario: a B2B SaaS platform with 10,000 tenants needs to shard the orders table by tenant_id.
-- Step 1: Create shard mapping
CREATE TABLE shard_map (
tenant_id INT PRIMARY KEY,
shard_id INT NOT NULL
);
-- Assign tenants to 4 shards using consistent hashing
INSERT INTO shard_map (tenant_id, shard_id)
SELECT id, consistent_hash(id, 4) FROM tenants;
-- Step 2: On each shard, create the orders table
CREATE TABLE orders (
id BIGSERIAL,
tenant_id INT NOT NULL,
total NUMERIC NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
-- Step 3: Application routing (pseudocode)
-- shard = lookup_shard(tenant_id)
-- connection = get_connection(shard)
-- connection.execute("INSERT INTO orders ...")
-- Step 4: Cross-shard aggregation (admin dashboard)
-- Fan out to all shards and merge:
results = []
for shard in all_shards:
r = shard.query("SELECT count(*), sum(total) FROM orders WHERE created_at > '2024-01-01'")
results.append(r)
total_count = sum(r.count for r in results)
total_revenue = sum(r.total for r in results)
Using Citus for transparent sharding in PostgreSQL:
-- Citus extension: distributed tables on PostgreSQL
CREATE EXTENSION citus;
-- Designate worker nodes
SELECT citus_add_node('worker1', 5432);
SELECT citus_add_node('worker2', 5432);
-- Distribute the orders table by tenant_id
SELECT create_distributed_table('orders', 'tenant_id');
-- Queries route automatically:
SELECT * FROM orders WHERE tenant_id = 42;
-- Citus routes to the correct worker node
Sharding too early. Sharding adds enormous complexity: cross-shard joins, distributed transactions, operational overhead. A single well-tuned PostgreSQL instance handles most workloads up to several TB. Exhaust vertical scaling and read replicas before sharding.
Choosing a shard key with high cardinality skew. If 80% of rows have status = 'active', sharding by status puts 80% of data on one shard. Analyze value distribution before choosing a shard key.
Allowing cross-shard joins in application queries. Cross-shard joins require fetching data from multiple shards and joining in the application or routing layer. This is slow and error-prone. Colocate related tables on the same shard.
Not planning for resharding. If you start with 4 shards and outgrow them, adding a 5th shard with modulo hashing requires moving ~20% of all data. Use consistent hashing or a lookup table from the start.
Sharding without addressing the cross-shard transaction problem. Distributed transactions (two-phase commit) across shards are slow and complex. Design the data model so transactions stay within a single shard.
Resharding Strategies:
Colocation:
-- In Citus, colocate related tables so joins stay shard-local
SELECT create_distributed_table('orders', 'tenant_id');
SELECT create_distributed_table('order_items', 'tenant_id',
colocate_with => 'orders');
-- This join is shard-local (fast):
SELECT o.id, oi.product_id FROM orders o
JOIN order_items oi ON o.id = oi.order_id AND o.tenant_id = oi.tenant_id
WHERE o.tenant_id = 42;
Global Tables (Reference Tables): Small, rarely-changing tables (countries, currencies, config) are replicated to every shard so joins do not require cross-shard communication.
MySQL sharding is typically done via Vitess (YouTube's sharding middleware) or application-level routing. Vitess provides a SQL-aware proxy that routes queries based on a vindex (virtual index). It supports resharding, cross-shard queries, and distributed transactions. Vitess is more mature for MySQL sharding than Citus is for PostgreSQL, having been in production at YouTube since 2011.
MySQL's native partitioning is single-instance only -- it does not distribute across servers. For multi-server sharding, an external solution like Vitess or ProxySQL is required.
Notion sharded their PostgreSQL database to handle growth beyond a single instance. They sharded by workspace_id (their tenant key), ensuring all data for a workspace lives on the same shard. The migration from a single database to sharded architecture took 6 months. Key decisions: consistent hashing with virtual nodes for future resharding, Citus for transparent routing, and a lookup table for workspace-to-shard mapping. Cross-shard queries were eliminated by colocating all workspace-related tables. After sharding, write throughput increased 4x and they could add capacity by adding shards without downtime.