Read replicas
For read-heavy workloads you can spread reads across one or more replica
databases while writes keep going to the primary. It is opt-in: without
WithReplicas, every operation uses the single primary connection, unchanged.
A downed replica costs you performance, not correctness. A read routed to a
replica that fails is retried against the primary automatically, and that replica
drops out of rotation for a cooldown before being probed again. The cooldown
defaults to 5 seconds and is tunable with WithReplicaDownCooldown.
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
- Reads route to a replica — both multi-row reads (
List,Iter, eager-loading) and single-row reads (First,Find,Count, and the aggregatesSum/Avg/Min/Max). - Writes (
Create,Update,UpdateFields,Delete) always go to the primary, including the write paths that read a row back (INSERT ... RETURNING, MSSQLSCOPE_IDENTITY()). - Reads inside
Client.Txuse the transaction's connection (the primary), so they always see the transaction's own writes. - Reads under
RowLevelSecurityNativestay on the primary — the policy is evaluated on the connection that set the session variable, so the read must not move to another pool.
Selection strategy
When more than one replica is configured, the strategy decides which healthy
replica serves each read. Set it with WithReplicaStrategy; the default is
round-robin:
client, err := quark.New("pgx", primaryDSN,
quark.WithReplicas(replica1DSN, replica2DSN),
quark.WithReplicaStrategy(quark.ReplicaLeastConn),
)
ReplicaRoundRobin(default) — advances an atomic cursor one slot per read; the most even distribution under steady concurrency.ReplicaRandom— picks a replica at random (uniform across healthy replicas); no shared cursor, so it avoids round-robin's single contended atomic.ReplicaLeastConn— picks the replica with the fewest in-use pool connections; best when replica query latencies are uneven.
Every strategy honours the failover cooldown below: a replica taken out of rotation is never chosen until its cooldown expires.
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()
// => fresh includes the row you just wrote — Sticky read it from the primary
Sticky is a no-op when no replicas are configured.
Routing is decided per statement, not per session. Quark sends a read to a
replica unless something about that read requires the primary: it is inside a
transaction, it is a write, it runs under native row-level security, or you
marked it Sticky.
Replication lag is therefore something you opt out of at the few call sites that cannot tolerate it, rather than a global mode you have to reason about everywhere.