Architecture No. 316

Data Partitioning skill for Claude Code

Pick shard keys from data, not from hope. Claude only recommends sharding after indexes, replicas, caching and native partitioning are ruled out, then picks the key from measured query patterns and runs a bundled simulator on real key frequencies to show skew, hot keys and how much data moves when a shard is added. It plans cross-shard queries, unique IDs and a rehearsed slot-by-slot resharding procedure before launch.

  • namedata-partitioning
  • size196 lines · ≈2,250 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Plans and reviews partitioning and sharding of data stores - when to partition within one database versus shard across nodes versus adopt distributed SQL, choosing partition and shard keys from real access patterns, hash, range, directory and logical-slot strategies, hot keys and celebrity tenants, cross-shard queries and transactions, globally unique IDs, online resharding with dual writes and backfill, Postgres declarative partitioning, and DynamoDB key design, with a key-skew simulator. Use when a table or database outgrows one node, picking a shard key, a shard runs hot, or planning a resharding migration.

Try asking

  • Our orders table is 4 TB and growing. Do we partition, shard, or something else?
  • Is tenant_id a good shard key? Here are row counts per tenant
  • Plan moving our largest customers onto dedicated shards without downtime

Use it when

  • A table or database is outgrowing a single node
  • Choosing a partition or shard key
  • One shard or partition runs much hotter than the others
  • Planning a resharding or tenant move

Skip it when

  • The problem is a slow query that an index would fix — use sql-query-tuning
  • You are designing tenant isolation rather than scale — use multi-tenancy

How to check the result

The skew simulation on real key weights passes the thresholds, most top queries include the key, and a staging rehearsal of a slot move matches row counts and checksums.

The common mistake it prevents

Sharding by a timestamp or another monotonic key, which sends every new write to the same shard while the others sit idle.

~/.claude/skills/data-partitioning/SKILL.md

--- name: data-partitioning description: Plans and reviews partitioning and sharding of data stores - when to partition within one database versus shard across nodes versus adopt… license: MIT ---

Done well means: sharding was chosen only after cheaper options ran out, the key was picked from measured access patterns and simulated for skew, almost every query carries the key, hot keys have a plan, IDs are unique without a central sequence, and resharding is a rehearsed procedure rather than an emergency.

Exhaust cheaper options first

PressureTry before sharding
Slow queriesIndexes, query fixes, sql-query-tuning
Read loadRead replicas, caching, materialized views
Table too big for maintenance (vacuum, index builds)Partitioning within one database
Old data dominatesPartition by time and archive or drop old partitions
Write throughput on one nodeBigger instance, batching, fewer indexes, async writes
StorageBigger volumes, compression, moving blobs to object storage

Shard when a single primary cannot hold the write rate or working set after these, or when tenancy and data residency demand physical separation.

OptionUse when
Native partitioning (Postgres PARTITION BY)One node is enough; you need pruning, faster maintenance, cheap retention
Application-level shardingClear tenant/user key, team can own routing and resharding
Citus, Vitess, PlanetScaleWant sharding with SQL and tooling instead of building routing
CockroachDB, Spanner, YugabyteDB, TiDBNeed automatic range splitting and distributed transactions; accept latency cost
DynamoDB, Cassandra, ScyllaDBKey-value access patterns known up front, massive scale

Workflow

- [ ] 1. Collect access patterns and size/growth numbers
- [ ] 2. List candidate keys; check each query includes it
- [ ] 3. Simulate skew with real key frequencies (scripts/shard_skew.py)
- [ ] 4. Choose the strategy (hash, range, directory, logical slots)
- [ ] 5. Plan hot keys, cross-shard queries, unique IDs, and transactions
- [ ] 6. Plan resharding before launch; rehearse it
- [ ] 7. Write the design and verification steps

1. Access patterns

For the top 10 to 20 queries by frequency and by cost, record: filter columns, sort, rows returned, read/write ratio, and latency target. Also: current size, growth per month, peak write rate, and the largest single tenant or user as a share of the total.

2. Key selection

A good shard key has:

  • High cardinality (many more distinct values than shards; ideally 100x+).
  • Even load: no value carries more than a fraction of one shard’s capacity.
  • Query locality: 90%+ of queries include it, and related rows share it (a tenant’s orders, items and invoices all keyed by tenant_id).
  • Stability: the value never changes for a row (changing it means moving the row).
WorkloadTypical keyWatch out for
B2B SaaStenant_idOne huge tenant; plan dedicated shards
Consumer appuser_idSocial features that join across users
Events, logs, metricsTime range (partition) + hash of source (shard)Monotonic time keys make the newest partition hot
Marketplace ordersbuyer_id or seller_idQueries by the other side become scatter-gather; denormalize
IoTdevice_idFleets with a few chatty devices

3. Simulate

Export real key frequencies (rows, requests, or bytes per key) and run:

# key,weight per line, e.g. from: SELECT tenant_id, count(*) FROM orders GROUP BY 1
python3 scripts/shard_skew.py tenant_rows.csv --shards 8
python3 scripts/shard_skew.py tenant_rows.csv --shards 16 --slots 4096 --max-skew 1.2

It reports skew (max/mean shard load) for hash-mod-N, a consistent-hash ring, and logical slots, the share of data moved when adding one shard, and keys heavier than half a mean shard. Exit code 1 means the key fails the thresholds. Target skew at or below 1.2; a hot key flagged here cannot be fixed by any hash function.

4. Strategy

StrategyProsConsUse when
Hash mod NEven spread, trivialChanging N moves almost everythingNever for stores that will grow; fine for fixed partition counts
Logical slots (hash mod S, S fixed e.g. 1024 or 4096, slots mapped to shards)Move whole slots to rebalance; mapping is a small tableNeed the slot map in routingDefault for application sharding
Consistent hashing with virtual nodesAdding a node moves about 1/NSkew depends on vnode countCaches, Dynamo-style stores
RangeRange scans, time-based retentionHot newest range with monotonic keysTime series partitions; ordered scans
Directory (lookup table key -> shard)Place any tenant anywhere, move individuallyLookup on every request (cache it)B2B with very uneven tenants, data residency

A common combination: directory for large or regulated tenants, logical slots for everyone else.

5. Hot keys, cross-shard work, IDs

Hot keys:

  • Celebrity tenant: give it a dedicated shard via the directory.
  • Write-hot single key (a global counter, a viral post): split it into N sub-keys (post:123#0..15) and sum on read, or buffer writes and apply in batches.
  • Monotonic keys in range-partitioned stores: prefix with a hash bucket or use random UUIDs for the clustering key.

Cross-shard queries:

NeedApproach
Admin or analytics across all dataReplicate to a warehouse (CDC); never scatter-gather in OLTP paths
Lookups by a secondary attribute (email -> user)Global lookup table keyed by that attribute, or a search index
Lists spanning shards (“all orders for seller” when sharded by buyer)Denormalized copy keyed the other way, maintained by events
Rare scatter-gatherParallel fan-out with per-shard timeouts and limits; cap result size

Transactions: keep each business transaction inside one shard by choosing a key that colocates its rows. When unavoidable, use a saga with compensations rather than two-phase commit across application shards.

IDs: no per-shard auto-increment exposed as global IDs. Use UUIDv7 (time-ordered, good index locality) or Snowflake-style IDs; optionally embed the slot in the ID so routing needs no lookup.

6. Resharding

Plan it before launch:

  1. Add the target shard(s); create schema.
  2. Mark moving slots or tenants as “migrating” in the routing table.
  3. Dual-write: writes go to old and new location (or capture changes via CDC from old).
  4. Backfill historical rows in batches, idempotently (upserts by primary key).
  5. Verify: row counts and checksums per slot match; compare a sample of reads.
  6. Cut over reads, then writes, slot by slot; keep the old copy read-only for a soak period.
  7. Delete old copies only after explicit approval.

Rehearse on staging with production-sized data and measure duration per slot. Moving whole logical slots (or whole tenants) keeps the procedure the same at every scale.

Postgres partitioning specifics

CREATE TABLE events (
  id         uuid        NOT NULL,
  tenant_id  bigint      NOT NULL,
  created_at timestamptz NOT NULL,
  payload    jsonb       NOT NULL,
  PRIMARY KEY (id, created_at)              -- unique constraints must include the partition key
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_10 PARTITION OF events
  FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE events_default PARTITION OF events DEFAULT;
  • Queries must filter on the partition key for pruning; check EXPLAIN shows only the expected partitions.
  • Create partitions ahead of time (pg_partman or a scheduled job); rows landing in the default partition block later attaching a partition for that range.
  • Retention: ALTER TABLE events DETACH PARTITION events_2025_01 CONCURRENTLY; then drop or archive it. Dropping is irreversible; confirm with the user.
  • Keep partition counts in the low hundreds to low thousands; planning time grows with partition count.

DynamoDB specifics

  • Each partition serves up to about 3,000 read units and 1,000 write units per second; adaptive capacity helps but does not fix a single hot key.
  • Design keys from access patterns (single-table design when patterns are known); add write sharding suffixes for hot partition keys.
  • GSIs have their own partitions; a low-cardinality GSI key (status) creates hot spots.

Gotchas

  • Choosing created_at as the shard key: every write hits the newest shard.
  • Sharding by a key that some queries lack turns them into scatter-gather forever.
  • Foreign keys and unique constraints do not span shards; enforce cross-shard uniqueness with a lookup table.
  • Connection counts multiply: app instances x pool size x shards.
  • Schema migrations must run on every shard; automate and track per-shard state.

Output contract

## Why partition / shard now
Measurements and options already exhausted
## Key and strategy
Key, strategy, simulation output (skew, moved share, hot keys)
## Query coverage
| Query | Includes key? | Plan if not |
## Hot keys, IDs, transactions
## Resharding procedure and rehearsal results
## Risks and rollback

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/shard_skew.py 182 lines · 6.4 KB
#!/usr/bin/env python3
"""Simulate how a candidate shard key spreads load across shards.

Input: a CSV or text file of observed keys, one per line as `key` or `key,weight`
(weight = rows, requests, or bytes for that key; default 1). A header line starting
with "key" is skipped. Use "-" to read stdin.

Usage:
    python3 scripts/shard_skew.py keys.csv --shards 8 [--vnodes 128] [--slots 1024]
                                  [--max-skew 1.25] [--hot-key-share 0.5]

For each strategy (hash mod N, consistent-hash ring with virtual nodes, fixed logical
slots mapped to shards) it prints per-shard load, skew (max / mean), and the share of
load that moves when growing from N to N+1 shards. It also lists the hottest keys.

Exit codes: 0 = key distributes acceptably, 1 = skew or hot keys exceed thresholds,
2 = bad input.
"""
import argparse
import bisect
import hashlib
import statistics
import sys
from collections import Counter


def h64(text: str) -> int:
    return int.from_bytes(hashlib.md5(text.encode("utf-8")).digest()[:8], "big")


def read_keys(path: str) -> Counter:
    fh = sys.stdin if path == "-" else open(path, encoding="utf-8")
    weights: Counter = Counter()
    with fh:
        for n, line in enumerate(fh, 1):
            line = line.strip()
            if not line or (n == 1 and line.lower().startswith("key")):
                continue
            key, _, w = line.partition(",")
            try:
                weights[key.strip()] += float(w) if w.strip() else 1.0
            except ValueError:
                sys.exit(f"error: line {n}: weight '{w}' is not a number")
    if not weights:
        print("error: no keys read", file=sys.stderr)
        sys.exit(2)
    return weights


class ModN:
    name = "hash mod N"

    def __init__(self, shards: int, **_):
        self.n = shards

    def shard(self, key: str) -> int:
        return h64(key) % self.n


class Ring:
    name = "consistent-hash ring"

    def __init__(self, shards: int, vnodes: int = 128, **_):
        points = sorted((h64(f"shard-{s}#vn-{v}"), s) for s in range(shards) for v in range(vnodes))
        self.hashes = [p[0] for p in points]
        self.owners = [p[1] for p in points]

    def shard(self, key: str) -> int:
        i = bisect.bisect(self.hashes, h64(key)) % len(self.hashes)
        return self.owners[i]


class Slots:
    name = "logical slots"

    def __init__(self, shards: int, slots: int = 1024, assignment=None, **_):
        self.slots = slots
        self.assignment = assignment or [s % shards for s in range(slots)]

    def shard(self, key: str) -> int:
        return self.assignment[h64(key) % self.slots]

    def grown(self, new_shards: int) -> "Slots":
        """Move the minimum number of slots to a new shard, taking evenly from the others."""
        assignment = list(self.assignment)
        target = self.slots // new_shards
        owned = Counter(assignment)
        new_id = new_shards - 1
        moved = 0
        for slot, owner in enumerate(assignment):
            if moved >= target:
                break
            if owned[owner] > target:
                assignment[slot] = new_id
                owned[owner] -= 1
                moved += 1
        return Slots(new_shards, self.slots, assignment)


def loads(strategy, weights: Counter, shards: int):
    per = [0.0] * shards
    for key, w in weights.items():
        per[strategy.shard(key)] += w
    return per


def moved_share(old, new, weights: Counter) -> float:
    total = sum(weights.values())
    moved = sum(w for k, w in weights.items() if old.shard(k) != new.shard(k))
    return moved / total


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("file")
    ap.add_argument("--shards", type=int, required=True)
    ap.add_argument("--vnodes", type=int, default=128)
    ap.add_argument("--slots", type=int, default=1024)
    ap.add_argument("--max-skew", type=float, default=1.25, help="max/mean shard load allowed (default 1.25)")
    ap.add_argument("--hot-key-share", type=float, default=0.5,
                    help="flag keys heavier than this fraction of a mean shard (default 0.5)")
    ap.add_argument("--top", type=int, default=5)
    args = ap.parse_args()
    if args.shards < 1 or args.slots < args.shards:
        print("error: need shards >= 1 and slots >= shards", file=sys.stderr)
        return 2

    weights = read_keys(args.file)
    total = sum(weights.values())
    mean = total / args.shards
    n = args.shards
    print(f"{len(weights)} distinct keys, total weight {total:,.0f}, {n} shards, mean load {mean:,.1f}\n")

    strategies = [
        (ModN(n), ModN(n + 1)),
        (Ring(n, vnodes=args.vnodes), Ring(n + 1, vnodes=args.vnodes)),
    ]
    slots = Slots(n, slots=args.slots)
    strategies.append((slots, slots.grown(n + 1)))

    print(f"{'strategy':<22} {'skew max/mean':>13} {'stdev %':>8} {'moved on N+1':>13}")
    results = {}
    for cur, nxt in strategies:
        per = loads(cur, weights, n)
        skew = max(per) / mean if mean else 0
        stdev = (statistics.pstdev(per) / mean * 100) if mean else 0
        mv = moved_share(cur, nxt, weights)
        results[cur.name] = (skew, per)
        print(f"{cur.name:<22} {skew:>13.3f} {stdev:>7.1f}% {mv * 100:>12.1f}%")

    best = min(results, key=lambda k: results[k][0])
    print(f"\nper-shard load ({best}):")
    for i, v in enumerate(results[best][1]):
        bar = "#" * int(40 * v / max(results[best][1]))
        print(f"  shard {i:>3}: {v:>12,.1f}  {bar}")

    print(f"\ntop {args.top} keys:")
    hot = []
    for key, w in weights.most_common(args.top):
        share_of_shard = w / mean if mean else 0
        flag = "  <-- hot key" if share_of_shard > args.hot_key_share else ""
        if flag:
            hot.append(key)
        print(f"  {key:<30} {w:>12,.1f}  {w / total * 100:5.1f}% of total, {share_of_shard:5.2f}x mean shard{flag}")

    problems = []
    if results[best][0] > args.max_skew:
        problems.append(f"best skew {results[best][0]:.3f} exceeds --max-skew {args.max_skew}")
    if hot:
        problems.append(f"{len(hot)} hot key(s) exceed {args.hot_key_share}x a mean shard; no hash spreads a single key")
    print()
    if problems:
        for p in problems:
            print(f"FAIL: {p}")
        print("Consider a higher-cardinality key, a compound key, dedicated shards, or salting hot keys.")
        return 1
    print("OK: key distributes within thresholds.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc