Skip to main content
Version: 1.1.0

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​

  • 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()

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 call sites that cannot tolerate it, rather than a global mode you have to reason about everywhere.