Skip to main content

By the end of this page you will know the four learner_read modes and exactly what each one guarantees, how to turn one on with SET or a connection string, how to read the route a statement actually took back out of EXPLAIN, and the one rule that never bends: an unmeasured replica is never treated as fresh.

Read freshness and routing

learner_read is a per-session setting, off by default. Turn it on and an ordinary autocommit SELECT becomes eligible to run against the learner replica the node you are already connected to happens to host, instead of paying for the full transactional path. It does not pick a different, fresher node for you: connect to a node with no learner covering the tables you asked for, and the statement just runs the normal way (see Learner nodes for what a learner actually is). Only autocommit SELECTs are eligible; inside an explicit transaction learner_read is ignored outright and the transaction keeps its ordinary snapshot.

The four modes​

learner_readWhat it does
off (default)Ordinary transactional reads. Nothing about the read path changes.
strongFresh read: each group's leader confirms its latest commit, the node's learner catches up to it, and the read waits out any commit still in flight on the tables it reads, then reads them all at one position. Every commit acknowledged before the read is in it, never a guess at how far behind the learner is. Bounded by the cluster's read wait (about ten seconds with the defaults); past it the read is refused.
staleBounded-stale read: serve straight from the learner's own applied data, at the smallest of its groups' read floors, as long as every floor advanced within max_staleness (or a built-in 500ms bound if you have not set one). A floor is the commit protocol's closed position at the replica: every commit at or below it is installed there and none can land there later, so the read is one consistent snapshot across all its tables, never a transaction half applied. Further behind than the bound, the read is refused, not served.
autoLet the router pick. Takes the stale path only when you have set max_staleness above zero and the learner's measured lag actually fits inside it; otherwise, including the max_staleness = 0 default, it takes strong. Never refuses: worst case it simply costs what strong costs.

A stale read on a learner whose read floor is not measurable yet (nothing applied on a group) is refused, like one past its bound. A strong read never falls back: it brings the learner current, or waits out the read wait and is refused, instead of returning early with an unfinished snapshot.

max_staleness: the budget​

max_staleness is the other half of the pair: how much staleness you are willing to accept, in whatever unit is convenient. It defaults to 0, meaning auto never takes the stale path unless you deliberately widen the budget, so staleness cannot enter a plan by accident.

Set it as a bare integer (milliseconds), or with a ms, s, or min suffix:

SET max_staleness = '750'; -- 750ms
SET max_staleness = '250ms'; -- 250ms
SET max_staleness = '5s'; -- 5 seconds
SET max_staleness = '1min'; -- 1 minute

An unparseable value resets the budget to 0, the safe direction: a typo can never leave the session staler than a value it did not actually manage to set.

The rule that keeps a stale read honest​

This is the rule the whole design answers to, stated plainly: an unmeasured replica lag never satisfies a budget. "We do not know how far behind this replica is" is treated exactly like "too far behind", never like "fresh enough". The router's own fitness check makes no exception for ignorance: a lag it could not measure fails the same test a lag that blew straight through the budget fails.

In practice, that means a stale read either gets served from data it can prove is within your bound, or it does not get served from that learner at all. A measured, over-budget lag fails the statement outright and names the group and exactly how many milliseconds behind it is. A learner with nothing applied yet is treated no better than one with no answer at all, and the read falls back to the ordinary, fully consistent path instead of guessing. Either way, you never get a quietly stale answer dressed up as a fresh one.

A refusal arrives as SQLSTATE 40001 (serialization_failure), the retryable class: the statement left no effect, and re-executing it is unconditionally safe. That is deliberate, and it covers both refusal shapes: a lag that measured over budget, and a lag that could not be measured at all. Both are transient conditions that a retry a moment later, or against a different node, will usually clear, so they belong in the class every client already knows how to retry rather than in a generic internal-error code that stops an application dead. See Error codes for the full retry contract.

Setting it: SET, or a connection string​

Set both with an ordinary SET, exactly like any other session setting:

SET learner_read = auto;
SET max_staleness = '2s';

Or carry them on the connection itself, so a pool never has to issue a SET on every new connection it opens. PostgreSQL's options DSN parameter takes -c key=value pairs, and ScramDB reads them the moment the first statement runs, on both the simple and extended query protocols:

postgresql://bi@node-a:5432/appdb?options=-c%20learner_read%3Dauto%20-c%20max_staleness%3D5s

That decodes to -c learner_read=auto -c max_staleness=5s: a BI tool's pool that always asks for auto with a 5 second budget, with nothing to configure inside the tool itself.

Reading the route back out of EXPLAIN​

EXPLAIN ANALYZE names the route a statement actually took, as its last line. Plain EXPLAIN does not: without executing the statement, the route it would take is not decided yet, so ScramDB prints the plan honestly and leaves that line off rather than guess.

CREATE TABLE orders (id BIGINT PRIMARY KEY, total NUMERIC);

EXPLAIN ANALYZE SELECT * FROM orders WHERE total > 1000;
Seq Scan on orders (cols: *)

Planning Time: 0.038 ms
Execution Time: 4.912 ms
Rows Returned: 214
Distributed Route: scatter(3 fragments, strong); buckets on learners 5, followers 3, leaders 0

Distributed Route: and the per-route /metrics counters (see How a read picks its route) share one vocabulary, so a dashboard label and an EXPLAIN line can never disagree about what to call the same decision:

  • oltp: the unchanged single-node path (cluster off, an explicit transaction, a write, or learner_read = off).
  • point(group N): a single-key lookup, served locally by the bucket's leader or forwarded to it in one hop.
  • local(strong) / local(stale): the whole statement against a replica this node itself hosts.
  • replica(NODE,strong) / replica(NODE,stale): the whole statement forwarded to one replica on NODE.
  • scatter(N fragments, strong); buckets on learners L, followers F, leaders D: gathered from N readers, each bucket read by one replica that holds it (learners first, then followers, the leader last; see Where fragments run), the same path an ordinary full scan takes; L, F and D count the buckets each kind of replica read.

The route ceiling: when even the best replica is too big​

Every one of those routes except oltp is only on the table up to a size ceiling: [cluster] route_ceiling_bytes, 64 MB (64 MiB) by default. Above it, a statement never rides a single replica, no matter how conveniently local or fresh that replica is. It scatters across bucket owners instead, exactly like an ordinary full scan. The estimate behind that decision is the same estimate_cardinality x avg_row_width cost model join placement already uses, never a second guess at how big a table is.

That is the honest answer to "what happens if I point a large report at learner_read = auto": nothing bad. A report that would flatten one replica scatters instead, the same as it would with learner_read left off; the setting only ever changes freshness and locality on the routes small enough for a single replica to carry safely.

Worked example: a transactional pool and a reporting pool​

A common pattern: one pool for the application's transactional path, left at every default, and a second pool for reporting that tolerates a couple of seconds of staleness in exchange for never adding load to the strict path. Neither pool needs a different node list; point both at the same nodes, and the session setting alone tells ScramDB which one you are running:

# Transactional pool: every default. Every read is fully current.
DATABASE_URL=postgresql://app@node-a:5432,node-b:5432,node-c:5432/appdb

# Reporting pool: same cluster, same nodes, up to 2 seconds stale.
REPORTING_URL=postgresql://reporting@node-a:5432,node-b:5432,node-c:5432/appdb?options=-c%20learner_read%3Dauto%20-c%20max_staleness%3D2s

The transactional pool never touches learner_read, so it stays at off and every statement takes the ordinary path, unaffected by any of this. The reporting pool asks for auto with a 2 second budget: a dashboard query on it runs against a covering learner whenever the router can prove one is within 2 seconds, and costs exactly what a strong read costs the rest of the time. Nothing about the SQL either pool sends has to change, and neither pool ever gets an answer it cannot prove is either current or within budget.

Next​

  • Distributed queries for how a statement's shape decides whether it touches one node or many in the first place.
  • Scaling for what a learner node is and how to add one.
  • Multi-region for reading a table locally from a region it is not homed in, the same learner_read / max_staleness pair at work across a WAN.