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.
strongWait-index read: bring the node's learner up to the leader's read index, bounded by a 5 second deadline, then read that caught-up snapshot. Leader-equivalent freshness, never a guess at how far behind it is.
staleBounded-stale read: serve straight from the learner's own already-applied snapshot, as long as it is within max_staleness (or a built-in 500ms bound if you have not set one). Further behind than that, 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.

Both strong and stale fall back to the ordinary OLTP path, quietly, when the node's learner has not applied anything at all yet. Once it has applied something, stale past its bound refuses outright, and strong waits out its deadline 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)

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): gathered across N bucket-owning readers, the same path an ordinary full scan takes.

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. ROUTE_CEILING_BYTES is 64 MiB (64 * 1024 * 1024 bytes, cluster/routing.rs): 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.