Treat read preference as server selection: modes, tags, staleness filters, latency windows, and explicit stale-read consequences.

Read Preference Modes, Tag Sets, maxStalenessSeconds, and Secondary-Read Consequences

Configure tagged members and a deliberately stale secondary, then observe actual PyMongo routing and max-staleness filtering.

Intermediate110–175 minutesReplica-set consistency/latency labMongoDB 8.3.8 · mongosh 2.10.0 · PyMongo 4.17.0Last reviewed: September 2026

Learning objectives

01

Differentiate all five read-preference modes as server-selection policies, not consistency levels.

02

Configure member tag sets and prove which member actually served a PyMongo read.

03

Explain ordered tag-set matching, latency windows, and why nearest can still be stale.

04

Demonstrate the 90-second minimum and coarse nature of maxStalenessSeconds.

05

Identify invariants that secondary reads can violate even when the query itself is correct.

Reproducible lab baseline

This lesson pins MongoDB Community Server 8.3.8 with mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim, mongosh 2.10.0, and PyMongo 4.17.0 where the driver is used. The mandatory topology is a disposable three-member replica set on one Docker host, with loopback-published diagnostic ports 27106–27108. Members communicate over a lesson-specific Docker bridge network by container DNS name. Authentication and TLS are disabled only for this isolated local learning topology. Feature Compatibility Version (FCV) is observed and never changed. Unless the experiment states otherwise, reads target the primary and writes use an explicitly named concern rather than assuming a global default. Run only one Chapter 15 topology at a time and budget roughly 3–4 GB of free RAM plus disk headroom. Local member tags such as east/west demonstrate routing semantics only; they do not simulate real WAN latency or failure domains. Atlas, Search, Vector Search, KMS, and Enterprise Advanced are not mandatory. Members are tagged with synthetic region/workload labels. Member C is also made non-voting, priority-0, and delayed so max-staleness behavior can be observed after a controlled wait. Product commands were not executed in this generation environment because Docker, mongod, mongosh, and PyMongo are unavailable here; expected invariants are documentation-derived and measured values must be recorded on the learner’s machine.

1. Read preference answers “where?”, not “what version is safe?”

Read preference is the client/server-selection policy used for replica-set reads. It does not change data visibility by itself. Every mode except primary can route to a secondary, and asynchronous replication means that secondary can be stale. Read concern must separately express the version/isolation requirement.

Mode Selection intent Failure/staleness consequence
primary Current primary only. Fails during no-primary windows; strongest default recency location.
primaryPreferred Primary if available, otherwise eligible secondary. Can become stale exactly during failover when it falls back.
secondary Eligible secondaries only. Fails when no secondary qualifies; stale reads allowed.
secondaryPreferred Eligible secondary if possible, else primary. Optimizes offload/availability, not currentness.
nearest Random eligible member inside latency window, primary or secondary. Latency preference is not a consistency guarantee.
isolated three-member replica-set setup (l3)
docker rm -f atlasmart-mongo-ch15-l3-a atlasmart-mongo-ch15-l3-b atlasmart-mongo-ch15-l3-c 2>/dev/null || truedocker network rm atlasmart-ch15-l3-net 2>/dev/null || truedocker volume rm atlasmart-mongo-ch15-l3-a-data atlasmart-mongo-ch15-l3-b-data atlasmart-mongo-ch15-l3-c-data 2>/dev/null || truedocker network create atlasmart-ch15-l3-netdocker run -d --name atlasmart-mongo-ch15-l3-a --network atlasmart-ch15-l3-net -p 127.0.0.1:27106:27017 -v atlasmart-mongo-ch15-l3-a-data:/data/db mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim --replSet atlasmart-rs15-l3 --bind_ip_alldocker run -d --name atlasmart-mongo-ch15-l3-b --network atlasmart-ch15-l3-net -p 127.0.0.1:27107:27017 -v atlasmart-mongo-ch15-l3-b-data:/data/db mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim --replSet atlasmart-rs15-l3 --bind_ip_alldocker run -d --name atlasmart-mongo-ch15-l3-c --network atlasmart-ch15-l3-net -p 127.0.0.1:27108:27017 -v atlasmart-mongo-ch15-l3-c-data:/data/db mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim --replSet atlasmart-rs15-l3 --bind_ip_alluntil mongosh "mongodb://127.0.0.1:27106/admin?directConnection=true" --quiet --eval 'quit(db.runCommand({ping:1}).ok===1?0:1)'; do sleep 1; donemongosh "mongodb://127.0.0.1:27106/admin?directConnection=true" --quiet --eval 'rs.initiate({_id:"atlasmart-rs15-l3",members:[  {_id:0,host:"atlasmart-mongo-ch15-l3-a:27017"},  {_id:1,host:"atlasmart-mongo-ch15-l3-b:27017"},  {_id:2,host:"atlasmart-mongo-ch15-l3-c:27017"}]})'until mongosh "mongodb://127.0.0.1:27106/admin?directConnection=true" --quiet --eval 'quit(db.hello().isWritablePrimary?0:1)'; do sleep 1; donefor i in $(seq 1 60); do  STATES=$(mongosh "mongodb://127.0.0.1:27106/admin?directConnection=true" --quiet --eval 'const s=rs.status(); print(s.members.map(m=>m.stateStr).sort().join(","))' || true)  [ "$STATES" = "PRIMARY,SECONDARY,SECONDARY" ] && break  sleep 1donemongosh "mongodb://127.0.0.1:27106/admin?directConnection=true" --quiet --eval 'printjson({server:db.version(),hello:db.hello(),fcv:db.runCommand({getParameter:1,featureCompatibilityVersion:1}).featureCompatibilityVersion,status:rs.status().members.map(m=>({name:m.name,stateStr:m.stateStr}))})' 

2. Add synthetic placement tags and a delayed “west” secondary

configure tags plus a delayed west member
let cfg = rs.conf();const a = cfg.members.find(m => m.host.startsWith("atlasmart-mongo-ch15-l3-a:"));const b = cfg.members.find(m => m.host.startsWith("atlasmart-mongo-ch15-l3-b:"));const c = cfg.members.find(m => m.host.startsWith("atlasmart-mongo-ch15-l3-c:"));a.tags = {region:"east",workload:"primary"};b.tags = {region:"east",workload:"analytics"};c.tags = {region:"west",workload:"analytics"};c.priority = 0; c.votes = 0; c.secondaryDelaySecs = 120;rs.reconfig(cfg);printjson(rs.conf().members.map(m => ({host:m.host,tags:m.tags,priority:m.priority,votes:m.votes,delay:m.secondaryDelaySecs||0})));const app=db.getSiblingDB("atlasmart");app.readpref_probe.drop();app.readpref_probe.insertOne({_id:"dashboard",version:1,value:"baseline"},{writeConcern:{w:"majority",wtimeout:5000}});
Do not mistake labels for geography

All three containers still run on one machine. Tags let you test selection rules, not cross-region round-trip time, correlated failure, bandwidth, or compliance boundaries.

3. Prove read source with PyMongo command monitoring

log the actual server connection used for each find
from pymongo import MongoClientfrom pymongo.monitoring import CommandListenerfrom pymongo.read_preferences import Primary, PrimaryPreferred, Secondary, SecondaryPreferred, NearestURI = "mongodb://127.0.0.1:27106,127.0.0.1:27107,127.0.0.1:27108/?replicaSet=atlasmart-rs15-l3"class Trace(CommandListener):    def started(self, event):        if event.command_name == "find":            print("find routed to", event.connection_id)    def succeeded(self, event):        pass    def failed(self, event):        passclient = MongoClient(URI, event_listeners=[Trace()], serverSelectionTimeoutMS=5000)base = client["atlasmart"]["readpref_probe"]policies = [    ("primary", Primary()),    ("primaryPreferred", PrimaryPreferred()),    ("secondary-east", Secondary([{"region":"east"}])),    ("secondaryPreferred-west", SecondaryPreferred([{"region":"west"}])),    ("nearest-east", Nearest([{"region":"east"}])),]for name, pref in policies:    coll = base.with_options(read_preference=pref)    try:        print(name, coll.find_one({"_id":"dashboard"}))    except Exception as exc:        print(name, type(exc).__name__, str(exc))client.close()

The event’s connection_id is the evidence. Run multiple reads: modes that have several eligible members can choose among them per operation. Do not infer routing from one sample.

4. Tag lists are ordered fallback sets

ordered tag-set fallback
from pymongo import MongoClientfrom pymongo.read_preferences import Secondaryclient = MongoClient("mongodb://127.0.0.1:27106,127.0.0.1:27107,127.0.0.1:27108/?replicaSet=atlasmart-rs15-l3")# First try a nonexistent region, then east, then any eligible secondary.pref = Secondary([{"region":"north"}, {"region":"east"}, {}])coll = client["atlasmart"]["readpref_probe"].with_options(read_preference=pref)print(coll.find_one({"_id":"dashboard"}))client.close()

MongoDB evaluates tag-set documents in order until one matches. The empty document is an explicit “any eligible member” fallback. Tags do not override mode: for example, tags are incompatible with primary, while nearest can apply tags to either a primary or secondary candidate.

5. maxStalenessSeconds is a coarse guardrail, not an SLA

deliberately invalid 30-second max staleness
from pymongo.read_preferences import Secondarytry:    Secondary(max_staleness=30)except Exception as exc:    print(type(exc).__name__, str(exc))# Current drivers require maxStalenessSeconds >= 90 seconds.
optional 95-second stale-member selection drill
# C is configured with secondaryDelaySecs=120. Allow real wall time to exceed the# protocol's 90-second minimum before testing staleness filtering.sleep 95python - <<'PY'from pymongo import MongoClientfrom pymongo.read_preferences import SecondaryURI="mongodb://127.0.0.1:27106,127.0.0.1:27107,127.0.0.1:27108/?replicaSet=atlasmart-rs15-l3"client=MongoClient(URI,serverSelectionTimeoutMS=5000)west = client["atlasmart"]["readpref_probe"].with_options(    read_preference=Secondary([{"region":"west"}], max_staleness=90))try:    print(west.find_one({"_id":"dashboard"}))except Exception as exc:    print(type(exc).__name__, str(exc))client.close()PY
Interpretation

After C’s estimated staleness exceeds 90 seconds, a west-only secondary preference with max_staleness=90 should have no eligible server and fail selection. The estimate is intentionally coarse because clients derive it from periodic last-write observations; do not market 90 seconds as an exact freshness SLA.

Production judgment. Use non-primary reads only for workloads whose invariants tolerate staleness or that are paired with an appropriate session/read concern. Tags are policy metadata that must be maintained during topology changes. Monitor per-member lag, server-selection failures, RTT distributions, and fallback frequency. Geographic routing can reduce read latency but increases topology/operational complexity and can surprise you during failover.

Bridge. Lesson 4 shows how a session carries causal time so a secondary read can wait for the write it depends on.

cleanup / full reset
docker rm -f atlasmart-mongo-ch15-l3-a atlasmart-mongo-ch15-l3-b atlasmart-mongo-ch15-l3-c 2>/dev/null || truedocker volume rm atlasmart-mongo-ch15-l3-a-data atlasmart-mongo-ch15-l3-b-data atlasmart-mongo-ch15-l3-c-data 2>/dev/null || truedocker network rm atlasmart-ch15-l3-net 2>/dev/null || true

Check your understanding

  1. Does read preference change data visibility guarantees?
  2. Can nearest choose a secondary?
  3. What is the minimum maxStalenessSeconds?
  4. Are tag sets evaluated as an unordered OR?
  5. Why can primaryPreferred violate a freshness-sensitive invariant during failover?
Review the answers

1. No. It selects eligible members; read concern controls the permissible data view.

2. Yes. It treats eligible primary and secondaries equivalently within the latency window.

3. 90 seconds.

4. No. The tag-set list is tried in order until one set matches eligible members.

5. When no primary is available it can fall back to a stale secondary.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.