Skip to content

Write Sharding

Write sharding is the practice of appending a shard suffix to a partition key value so that traffic for one logical key is spread across several physical partitions. It’s the standard mitigation for a hot partition when the low-cardinality key can’t be avoided.

STATUS#pending      →   STATUS#pending#0
                        STATUS#pending#1
                        ...
                        STATUS#pending#9

One logical queue, ten partitions, ten times the throughput ceiling.

Why you’d need it

Each DynamoDB partition caps at 1,000 WCU/sec and 3,000 RCU/sec. A partition key value receiving more than that throttles regardless of table-level capacity.

Some keys are legitimately low-cardinality. A global leaderboard, a status queue, a per-day event log, an admin enumeration index. When the access pattern genuinely requires collecting many items under one logical key, sharding is how you keep that key under the per-partition ceiling.

The cost is that reads become fan-out. Every query against a sharded key becomes N queries, merged and re-sorted in application code. That’s the trade you’re making.

Two sharding strategies

Calculated sharding

Derive the shard deterministically from an attribute of the item, usually a hash of its ID:

const SHARD_COUNT = 10
const shard = Math.abs(hashCode(orderId)) % SHARD_COUNT
// pk: STATUS#pending#3

The property that matters: the same item always maps to the same shard. That means you can update or delete the item without scanning all shards to find it. If you ever need to address an individual item through the sharded key, calculated sharding is mandatory.

Distribution depends on your hash quality. A decent hash over sufficiently random IDs gives near-uniform spread. ULIDs and UUIDs hash well; sequential integers do not, so hash them rather than using modulo directly.

Random sharding

Pick a shard at random on write:

const shard = Math.floor(Math.random() * SHARD_COUNT)

Distribution is perfectly uniform. The cost is that you no longer know which shard an item is in, so updates and deletes require either a fan-out search or storing the shard number as an attribute you look up first.

Use random sharding only for write-once, read-in-bulk data: event logs, telemetry, append-only audit trails. For anything mutable, use calculated sharding.

Choosing a shard count

Start from your throughput requirement:

shards = ceil(peak writes per second / 1000)

Then add headroom, because peaks are lumpier than averages. If you expect 4,000 writes/sec, 10 shards is reasonable, not 4.

Resist going higher than you need. Every additional shard adds a query to every read, and each of those queries has its own round trip and minimum 1 RCU charge. At 100 shards, a read that returns 20 items costs 100 queries. The read amplification is the real constraint, not the write ceiling.

Changing the shard count later is a migration. Items land on a different shard under a new modulo, so existing data has to be rewritten. Choose deliberately, and if you expect growth, pick a count with room in it rather than planning to expand.

The read path

Fan out across all shards in parallel, then merge:

const SHARD_COUNT = 10

async function getPendingOrders(limit = 50) {
  const results = await Promise.all(
    Array.from({ length: SHARD_COUNT }, (_, shard) =>
      OrderEntity.query
        .byStatus({ status: 'pending', shard })
        .go({ order: 'desc', limit })
    )
  )
  return results
    .flatMap(r => r.data)
    .sort((a, b) => b.orderId.localeCompare(a.orderId))
    .slice(0, limit)
}

Two details worth noticing.

Each shard is queried with the full limit. To return the 50 newest overall you must fetch up to 50 from each shard, because they could all be in one. You read up to limit × shards items to return limit.

The merge needs a comparable sort key. ULIDs make this trivial because they’re chronologically ordered across partitions. A per-shard sequence number would not be comparable, and the merge would be meaningless.

Pagination is the hard part

Fan-out queries don’t paginate cleanly. A LastEvaluatedKey is per-shard, so a cursor for a merged result set has to encode N cursors plus the merge position. Most implementations either:

  • Return the first page only, and accept that deep pagination isn’t supported
  • Paginate by time window instead of by cursor (orders between T1 and T2)
  • Track N cursors in an opaque encoded token

If your access pattern requires deep pagination over a sharded key, reconsider whether sharding is the right structure at all.

ElectroDB implementation

const SHARD_COUNT = 10

attributes: {
  shard: {
    type: 'number',
    required: true,
    default: (item) => Math.abs(hashCode(item.orderId)) % SHARD_COUNT,
    readOnly: true,
  },
},
indexes: {
  byStatus: {
    index: 'gsi1',
    pk: {
      field: 'gsi1pk',
      composite: ['status', 'shard'],
      template: 'STATUS#${status}#${shard}',
    },
    sk: {
      field: 'gsi1sk',
      composite: ['orderId'],
      template: 'ORDER#${orderId}',
    },
  },
},

Marking shard as readOnly matters: if the shard could change on update, the item would move partitions, and DynamoDB would leave the original behind rather than relocating it.

Common mistakes

Sharding before you need it. Read fan-out is a permanent tax on every query. Enable Contributor Insights, confirm the hot key is real, then shard. Most tables never require it.

Random sharding on mutable items. You lose the ability to address an item, and recovering it means fan-out on every update.

Sharding when a sparse index would work. If the hot key is a status queue and only a small fraction of items are ever in that status, a sparse index keeps the partition naturally small and may remove the need to shard entirely. Check this before reaching for shards.

Sharding a hot item. If a single record is the bottleneck, sharding the partition key changes nothing. Cache it or coalesce the writes.

Forgetting the shard in the read path. A query that omits the shard component returns one shard’s worth of data and looks like it works. Silent partial results are worse than errors.


Sharding is a real cost you should only pay when the key design leaves no alternative. I’m building singletable.dev to surface cardinality problems before they become throttling incidents.

← All glossary terms