Skip to main content
Version: 1.8.0

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 aggregates Sum/Avg/Min/Max).
  • Writes (Create, Update, UpdateFields, Delete) always go to the primary, including the write paths that read a row back (INSERT ... RETURNING, MSSQL SCOPE_IDENTITY()).
  • Reads inside Client.Tx use the transaction's connection (the primary), so they always see the transaction's own writes.
  • Reads under RowLevelSecurityNative stay 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.