Read/write splitting can be implemented in application code, in the database driver, or in a connection pooler/proxy. Compare those layers, and describe what commonly breaks when a team turns splitting on.
answer
- route per transaction, not per statement
- SELECT ... FOR UPDATE / nextval / writing functions
- session state doesn't travel between nodes
- PgBouncer pools, it does not split
- failover: promoted replica, stale topology
basics
~20 sApplication-level routing is explicit and safest: the code declares an operation read-only and gets a replica connection. Driver and proxy routing infer intent from statements, which breaks on transactions, session state, SELECT ... FOR UPDATE, and writes hidden inside functions. Whatever the layer, routing must be per transaction, not per statement.
solid answer
~60 s**Application/framework level** — an explicit read-only marker (a second datasource, a read-only transaction attribute) chooses the replica. Intent is declared by the developer, visible in code review, and testable. Downside: every call site must be classified, and mistakes are silent staleness. **Driver level** — the client library routes based on transaction read-only flags or statement type. Less code, but it still guesses. **Proxy/pooler level** — ProxySQL, pgpool-II, RDS Proxy, Vitess. Zero application change and central lag-aware routing, but the proxy only sees SQL text, so it must sniff statements. PgBouncer notably does *not* route by statement at all. **What breaks:** - A write inside a transaction routed to a replica → "cannot execute INSERT in a read-only transaction". - Splitting statements of one transaction across nodes — routing must be per *transaction*. - `SELECT ... FOR UPDATE`, `SELECT nextval(...)`, and read-only-looking calls to functions/procedures that write. - Session state: temp tables, `SET` parameters, session variables, advisory locks, prepared statements — not shared between nodes, and destroyed by transaction-level pooling. - Read-after-write staleness on paths nobody classified. - Failover: a promoted replica or a demoted primary must be re-detected, and connection pools sized per node.
go deeper
Know that the split exists and that reads can go to replicas while writes must go to the primary.
Compare the layers and name the classic breakages: writes inside read-only transactions and SELECT ... FOR UPDATE.
Own the rollout: per-transaction pinning, session-state hazards, functions that write, failover/topology handling, per-node pool sizing and observability.
Argue for placing routing where intent is expressed and treat staleness tolerance as a per-use-case contract, with the proxy providing pooling, health and failover rather than semantic guesses.
## The decision the layer has to make For each unit of work, choose primary or replica. Getting it right requires knowing two things the SQL text alone doesn't tell you: does this unit of work write, and does it need fresh data? Every design differs in how it learns those. ## Layer 1 — application / framework The code declares intent. In Spring, `@Transactional(readOnly = true)` plus a routing datasource; in other stacks, an explicit `readReplica()` handle or a repository split. Rails, Django and friends offer equivalent "connected to reading role" blocks. - **Strength:** intent is declared, not inferred. A reviewer can see that a checkout balance check runs against the primary. It composes with read-after-write policy: the same layer that knows the user just wrote can force primary reads. - **Weakness:** coverage. Every read path must be classified, and forgetting one is a silent bug that only shows up as stale data. Retrofitting into a large codebase is real work, and libraries that open their own connections escape it. ## Layer 2 — driver / client library The driver routes based on the connection's read-only flag or by inspecting statements. MySQL Connector/J and several JDBC drivers support replica-aware URLs. - **Strength:** central, little application code. - **Weakness:** it still has to guess when the application doesn't set the read-only flag, and it inherits every hazard of statement sniffing below. ## Layer 3 — proxy / pooler ProxySQL, pgpool-II, MaxScale, HAProxy with lag checks, RDS Proxy, Vitess. The proxy parses SQL and applies rules: `SELECT` → replica, everything else → primary, with exceptions for transactions and pattern-matched queries. - **Strength:** no application change; centralised lag-aware routing, replica health checks, and failover handling; consistent behaviour across many services and languages. - **Weakness:** it infers intent from text. And an important practical note — **PgBouncer does not do read/write splitting**; it is a connection pooler only. Teams routinely assume otherwise. ## The breakage list **Transactions.** A transaction is the unit, not the statement. If `BEGIN; SELECT; UPDATE; COMMIT;` has its SELECT sent to a replica and its UPDATE to the primary, you have destroyed atomicity and isolation. Correct implementations pin the whole transaction to one node based on its first statement or its declared read-only flag — which means a read-only-declared transaction that later writes must fail loudly rather than silently re-route. **Reads that are actually writes.** `SELECT ... FOR UPDATE` and `FOR SHARE` take locks and must run on the primary. `SELECT nextval('seq')` advances a sequence. `SELECT my_function(...)` may insert, update, or call an external system. Statement sniffing sees "SELECT" and gets all three wrong. **Session state.** Temporary tables, `SET`/session variables, advisory locks, server-side prepared statements, `LISTEN`/`NOTIFY`, and cursors all live on one connection on one node. Splitting means a session's state may not be where the next statement lands. This compounds with transaction-level pooling, which recycles connections between transactions and destroys session state regardless of splitting. **Autocommit mode.** In autocommit, each statement is its own transaction and routing per statement is legal — so behaviour differs between an ORM running in autocommit and one wrapping everything in explicit transactions. Many surprises trace back to this difference. **Read-after-write.** Turning on splitting exposes every unclassified read-after-write path at once. Pair the rollout with a stick-to-primary-after-write policy or causal tokens. **Failover and topology change.** After a promotion the old primary may accept connections as a read-only standby; a router that hasn't noticed sends writes there and gets errors, or worse, sends reads to a node that is being rebuilt. Routing needs authoritative topology discovery and quick, correct failover behaviour, plus connection-pool sizing per node so a replica loss doesn't overwhelm the primary. **Observability.** Instrument, per node, the query rate, error rate, lag, and how many transactions were routed each way. Without that, a split that silently stopped working (everything back on the primary, or reads going to a badly lagging replica) is invisible. ## A defensible recommendation Do the routing where intent lives — the application — and use the proxy for what it is genuinely good at: connection pooling, replica health checks, lag-based eviction, and failover. Roll out per endpoint, starting with obviously safe reads (reports, search, listing pages), and keep a kill switch that sends everything back to the primary. Treat "which reads may be stale" as a product decision recorded in code, not as a regex in a proxy config.
- Why is routing per statement rather than per transaction dangerous?Because a transaction's statements must see one consistent snapshot and share one lock and undo context. Sending some statements to a replica and others to the primary means the reads and writes are on different nodes, so atomicity and isolation are gone and the transaction cannot roll back coherently. Correct implementations pin the whole transaction to a node at BEGIN, based on its declared read-only flag or its first statement.
- A statement-sniffing proxy routes `SELECT get_next_invoice_number()` to a replica. What goes wrong?The function writes — it advances a sequence or updates a counter — so it either fails with a read-only transaction error or, on an engine that allows it, produces incorrect behaviour. Statement text says SELECT while the semantics are a write, which is exactly the class of error text-based routing cannot detect. Rules must be hand-maintained for such functions, or the call must be declared as a write in application code.
saying these in an interview costs you the question
- Believing PgBouncer performs read/write splitting.
- Routing individual statements of a transaction to different nodes.
- Assuming any statement starting with SELECT is safe on a replica.
- Ignoring session state — temp tables, SET, advisory locks, prepared statements.
- Turning splitting on globally without a read-after-write policy or a kill switch.