Sharding
For datasets too large for one database, ShardRouter partitions data across
several shard databases by a shard key (e.g. user_id, region). Each
row lives on exactly one shard; a query routes to the shard that owns its key.
Unlike read replicas, shards hold disjoint data and scale
writes as well as reads.
ShardRouter is a ClientProvider, so it drops into quark.For[T] like any
client — the rest of the ORM is unaware of sharding.
shards := map[string]*quark.Client{
"shard-a": clientA, // each is a normal quark.New(...) client
"shard-b": clientB,
}
router, err := quark.NewShardRouter(
shards,
quark.DefaultShardResolver, // reads the key set by WithShardKey
quark.HashShardFunc([]string{"shard-a", "shard-b"}), // FNV-1a hash → shard
)
// The caller supplies the shard key (a string) per operation, via the context.
shardCtx := quark.WithShardKey(ctx, user.Region) // shard by region
_ = quark.For[User](shardCtx, router).Create(&user) // → the shard owning user.Region
got, _ := quark.For[User](shardCtx, router).Where("id", "=", user.ID).List()
A complete, self-contained runnable example (two SQLite shards, no Docker) lives
in examples/sharding/ —
run it with go run ./examples/sharding/main.go. It routes accounts by shard
key, proves the data is disjoint per shard, and shows the keyless-query
rejection.
Routing writes by the entity (ShardKeyer)
When a model owns its shard key, implement ShardKeyer so the key-deriving
logic lives on the model — not repeated at every call site — and route writes
with WithShardKeyOf:
type User struct {
ID int64 `db:"id" pk:"true"`
TenantID string `db:"tenant_id"`
// ...
}
// The model declares which field its partition is keyed on.
func (u User) ShardKey() string { return u.TenantID }
// WithShardKeyOf reads it — no need to repeat user.TenantID at the call site.
ctx = quark.WithShardKeyOf(ctx, user)
_ = quark.For[User](ctx, router).Create(&user) // → the shard owning user.TenantID
WithShardKeyOf(ctx, entity) is exactly WithShardKey(ctx, entity.ShardKey()).
It applies to writes, where you hold the entity. Reads carry no entity, so
they keep passing the key to WithShardKey directly.
An entity whose ShardKey() returns an empty string fails to route — the same
as a missing key, and never a silent fan-out.
How routing works
DefaultShardResolverreads the shard key from the context (WithShardKey);ShardKeyFromContextexposes that value if you need it. You can supply your ownShardResolverto read an existing request value.- The
ShardFuncmaps that key to a shard name.HashShardFuncis the stable hash-mod default; supply your own for range, geo, or lookup-table policies — it is the seam you control for resharding. - The query runs on that shard's
*Client, unchanged.
A query without a shard key in context errors — there is no implicit cross-shard fan-out (forgetting the key fails loudly rather than silently querying every shard).
Scatter-gather (cross-shard reads)
To read across shards — explicitly, never as a fallback for a forgotten key
— use ScatterGather. It runs the same query on every shard concurrently and
merges the rows. Supply a comparator and a global limit via ScatterMerge for a
correct global top-N:
top, err := quark.ScatterGather(ctx, router,
func(q *quark.Query[Account]) *quark.Query[Account] {
return q.OrderBy("balance", "DESC").Limit(10) // each shard's top 10
},
quark.ScatterMerge[Account]{
Less: func(a, b Account) bool { return a.Balance > b.Balance },
Limit: 10, // global top 10 across all shards
},
)
ScatterCount sums per-shard counts (exact, since shards are disjoint):
n, err := quark.ScatterCount[Account](ctx, router, nil) // total across all shards
// => n is the exact total — shards are disjoint, so per-shard counts just sum
If any shard errors, the call returns that error rather than a silently partial result.
Aggregates beyond count (AVG, MIN, MAX) and cross-shard GROUP BY are
not merged; run those per shard. Merging them correctly needs more than the
per-shard result rows — an average, for instance, needs each shard's count as
well as its mean — so Quark returns nothing rather than a plausible-looking wrong
number.
Limits
- No cross-shard joins — a
JOINonly sees the resolved shard. - No cross-shard transactions — a
Txis bound to one shard's client; there is no two-phase commit. Design the model so each operation stays within a shard (choose the shard key well; denormalize where needed). - Scatter-gather is an explicit opt-in — cross-shard reads happen only
through
ScatterGather/ScatterCount(above), never as the fallback of a forgotten key. Aggregates beyond count and cross-shardGROUP BYare not merged. - Resharding (changing the
ShardFunc+ migrating data) is an operator task; the shard set is fixed at construction.
Multi-tenancy composes orthogonally: a shard's *Client can itself sit behind a
TenantRouter. Sharding is a router in front of ordinary clients, not a mode
inside the client, which is what lets the two stack without either knowing about
the other.