Read replicas
For read-heavy workloads you can spread reads across one or more replica
databases while writes continue to go to the primary. It is opt-in — without
WithReplicas, every operation uses the single primary connection, unchanged.
If a replica goes down, a read routed to it fails over to the primary
automatically and the replica is taken out of rotation for a cooldown
(default 5s, tunable with WithReplicaDownCooldown), after which it is retried
— so a downed replica degrades performance, not correctness.
client, err := quark.New("pgx", primaryDSN,
quark.WithReplicas(replica1DSN, replica2DSN),
quark.WithMaxOpenConns(16),
)
New opens one connection pool per replica DSN (same pool options and dialect
as the primary) and pings each. Close closes them all.
What routes where
- Multi-row reads (
List,Iter, eager-loading) round-robin across the replicas. - Writes (
Create,Update,UpdateFields,Delete) always go to the primary. - Reads inside
Client.Txuse the transaction's connection (the primary), so they always see the transaction's own writes.
Single-row reads (First/Find/Count) currently stay on the primary — they
share an execution path with INSERT ... RETURNING, so routing them is a
follow-up. Multi-row reads, the common scaling case, do route.
Consistency: stale reads and Sticky
Replicas are typically replicated asynchronously, so a read from a replica may
return slightly stale data — it may not yet reflect a write you just made
on the primary. When a read must observe a recent write (read-your-writes), pin
it to the primary with quark.Sticky:
// Write goes to the primary.
_ = quark.For[User](ctx, client).Create(&u)
// A normal read may hit a replica that hasn't caught up yet.
// Sticky pins this read to the primary so it sees the write.
fresh, _ := quark.For[User](quark.Sticky(ctx), client).
Where("id", "=", u.ID).
List()
Sticky is a no-op when no replicas are configured.
See ADR-0015 for the routing model and its rationale.