Replication and read replicas
A replica is a second Postgres server that keeps a live copy of your database. It can take heavy reads off the primary and stand ready to take over if the primary dies. It is always a little behind, though, and this lesson shows you how to design for that.
By the end you can
- Explain the WAL and streaming replication in plain words
- Choose between asynchronous and synchronous replication, knowing what each one costs
- Spot the read-your-own-writes bug and decide which reads must stay on the primary
- Create a replica with
pg_basebackup -Rand measure its lag with SQL - Route reads in Prisma with the read-replicas extension and
$primary()
The everyday picture
Head office keeps the master ledger. Every time a clerk writes an entry, she also writes one line in a diary: "page 41, line 7: add 12 cartons of milk". A runner carries the new diary lines to two branch offices in other cities all day long. The branch clerk copies each line into the branch's own ledger, in the same order.
The branches can answer "what did store 27 sell last month?" without calling head office, but they never write entries themselves. Their copy is always a little behind, because the runner needs time to travel and the clerk needs time to copy.
If head office floods in the monsoon, a branch can become the new head office, because its ledger is almost complete. And if a head office clerk writes something wrong, the runner carries the mistake to every branch too. Postgres replication works this way, line by line.
One primary, several read-only copies
In a replicated setup one server is the primary, the only one that accepts INSERT, UPDATE and DELETE. The others are replicas, also called standbys. They hold a full copy of the data and answer SELECT queries. A write on a replica fails with ERROR: cannot execute INSERT in a read-only transaction.
Teams add replicas for three reasons:
- Spread the reads. Reports, dashboards and searches move to a replica, so the primary keeps its CPU and disk for writes.
- Isolate heavy work. A 40-second month-end report on the primary competes with every upload from the store tills (the POS). On a replica it only slows other report users.
- Survive a failure. If the primary's machine dies, a replica can become the new primary in seconds or minutes, instead of a restore that takes hours.
Replicas do not add write capacity. Every replica must apply every write the primary makes, so writes still go through one machine. If writes are your limit, look at Partitioning big tables and Sharding and consistent hashing.
Quick check
The primary sits at 90% CPU, and almost all of that work is till uploads, which are writes. Will two new read replicas fix it?
Replicas take reads off the primary. They cannot take writes, and they repeat every write themselves. Synchronous replication only makes each commit wait longer. A write bottleneck needs a bigger server, less write work, or sharding.
The WAL: a diary that every copy replays
Before Postgres changes any data file, it appends a short record of the change to the WAL, the write-ahead log. A COMMIT is safe once its WAL records are flushed to disk; the table files can be updated later. After a crash, Postgres replays the WAL from the last checkpoint, so no committed work is lost.
A replica is a server that never finishes this crash recovery. It starts from a copy of the primary's files, then keeps receiving the primary's WAL over the network and replaying it, record after record. This is streaming replication. A walsender process on the primary sends; a walreceiver on the replica receives.
Every WAL record has a position called the LSN. For each replica, Postgres tracks how far it has written, flushed to disk and replayed the WAL. Queries on the replica see only replayed changes. The gap between the primary's newest LSN and the replica's replayed LSN is the replication lag.
Quick check
A replica has received WAL up to position 1,000 and flushed it to disk, but has replayed only up to 940. What can queries on the replica see?
Queries read the data files, and those change only when a record is replayed. Received WAL protects the data in a failover, but readers do not see it yet. The replica keeps serving queries while it catches up, showing the state as of its replay position.
In one data centre, lag is usually milliseconds. It grows when the replica cannot keep up: replay runs in a single process, so many parallel writers on the primary can produce WAL faster than one replica applies it, and a smaller replica machine makes this worse.
Async or sync: when is a write "done"?
By default replication is asynchronous. When your API commits, the primary flushes the WAL to its own disk and replies "done". The WAL goes to the replicas a moment later. Commits stay fast, and a slow replica never slows the primary. The cost: if the primary's disk is destroyed, the last few milliseconds (or, if a replica was lagging, seconds) of committed sales may never have reached any replica. After a failover they are gone.
With synchronous replication you list replicas in synchronous_standby_names, and each COMMIT waits until a replica confirms. A confirmed sale then exists on two machines. The price comes in two forms:
- Every commit pays a network round trip. In one data centre that is often well under a millisecond. To a replica in another city it can be tens of milliseconds on every single commit.
- If the synchronous replica goes down, commits stop. Postgres does not fall back to async on its own. Every COMMIT waits (you can see the wait event
SyncRepinpg_stat_activity) until the replica returns or someone changes the setting. The usual protection is two candidate replicas withANY 1 (replica1, replica2), so either one can confirm.
The setting synchronous_commit decides what "confirms" means, and you can change it per transaction with SET LOCAL:
| synchronous_commit | COMMIT returns when | Good for |
|---|---|---|
local (or no sync replicas listed) | The primary's disk has the WAL | Most apps; fastest safe default |
remote_write | The replica has received it and handed it to its operating system | Survives a primary crash, cheaper than on |
on (the default, with sync replicas listed) | The replica has flushed it to its disk | Payments, stock moves you must never lose |
remote_apply | The replica has replayed it, so queries there can see it | Rare; when reads on the replica must see the write |
Most teams start async and add a synchronous standby in the same data centre only when the business says losing even one second of sales is unacceptable. That is a decision about RPO (Backups and disaster recovery).
Quick check
Replication is asynchronous, the default. The primary's disk is destroyed one second after a COMMIT returned "done", and you promote a replica. What may be missing on it?
Async means the primary replies once its own disk has the WAL, and sends it on a moment later. Whatever had not left the primary is gone. That window is usually milliseconds, and seconds if the replica was lagging. Waiting for a replica before replying is what synchronous replication does.
Replication lag and the read-your-own-writes bug
Lag causes one classic bug. A user saves something, the app reads it back from a replica, and the replica has not replayed the save yet. The user sees old data and thinks the save failed.
The fix is to decide, read by read, how fresh the answer must be.
Must stay on the primary:
- Any read the user makes right after their own write: the list after a save, the order screen after "Place order".
- A read that decides a write: "is there stock left?", "is this voucher already used?". These belong inside a transaction on the primary anyway (Transactions, ACID, isolation and locks).
- Security data: logins, roles, a revoked device. A replica that is 5 seconds behind would let a blocked user in for 5 more seconds.
- Payment and order status that the customer is waiting for.
Fine on a replica: dashboards, reports, exports, product catalogue and search, history older than a minute, anything the screen already labels "updated a moment ago".
For reads right after a user's own write, three patterns cover most cases: return the saved object in the POST response, send that one follow-up read to the primary, or send all of that user's reads to the primary for a few seconds. The lesson CAP, PACELC and consistency builds the last one with Redis.
Think first: the reporting replica runs a 2-minute month-end query. Meanwhile the primary runs VACUUM on the same table. What happens on the replica?
VACUUM on the primary removes old row versions, and that removal travels to the replica in the WAL. But the long query on the replica may still need those old rows. Replay has to choose: wait, or cancel the query. Postgres waits up to max_standby_streaming_delay (30 seconds by default). During that wait replay stops, so lag grows for every reader of this replica. After 30 seconds it cancels the query with ERROR: canceling statement due to conflict with recovery.
On a reporting replica you can raise that delay, or set hot_standby_feedback = on so the primary keeps the rows the replica still needs, at the cost of dead rows piling up on the primary (Running Postgres in production). Many teams accept more lag on the reporting replica and keep a separate replica with default settings for failover.
Failover: when the primary dies
Failover has three steps. First, make sure the old primary is really down and cannot come back. Second, promote the most up-to-date replica with SELECT pg_promote();. Third, point the applications at the new primary and let them reconnect.
The first step is the dangerous one. Say the primary did not die; only the network to it dropped for a minute. You promote the replica, the network comes back, and now some app servers write to the old primary and some to the new one. This is split brain: two versions of the same receipts and stock moves that nobody can merge automatically. Every failover plan must say how the old primary is stopped: shut it down, block it at the firewall, or let a tool fence it.
- Manual promotion fits a small team with one replica, if the steps are written down and practised. Expect minutes of downtime, mostly spent noticing and deciding.
- Patroni is the common open-source tool for automatic failover. A Patroni agent beside each Postgres server holds or watches a single leader lock in etcd or Consul. Only the lock holder runs as primary, and a primary that cannot renew its lock demotes itself, which prevents split brain.
- Managed services such as Amazon RDS, Google Cloud SQL and Azure Database for PostgreSQL detect the failure, promote a standby and move a DNS name for you, usually within a minute or two. On some of them the failover standby cannot serve reads; read replicas are a separate feature.
Whatever you choose, connections to the old primary break and in-flight transactions fail, so the API needs timeouts, reconnects and safe retries (Failure is normal: timeouts, retries, backoff and circuit breakers). Afterwards, pg_rewind can turn the old primary into a replica of the new one without a full copy.
Logical replication: copy chosen tables to another database
Streaming replication copies the whole server, byte for byte. The replica must run the same major version, has exactly the same tables and indexes, and is read-only. Logical replication works at the level of rows instead. The primary publishes changes for chosen tables, and another database subscribes to them.
The subscriber is a normal, writable database. It can have its own report indexes and tables, and even a newer Postgres version. So logical replication is used to feed a reporting database, to feed other systems through change data capture (Beyond SQL: choosing the right data store), and to upgrade major versions with little downtime.
The limits: schema changes (DDL) and sequence values are not replicated, so migrations run on both sides; tables need a primary key; and it uses a replication slot, which keeps WAL on the primary until the subscriber reads it.
Build it
We will turn a second server into a streaming replica, measure its lag, and route reads from NestJS with Prisma. The commands use the Ubuntu packages for PostgreSQL 16, with the primary at 10.0.1.10 and the new replica at 10.0.1.11. Try it on two test machines or two local clusters on different ports. Replication cannot run in a browser, but the two Practice exercises below, choosing where a read goes and measuring lag in bytes, can.
-
Prepare the primary
The defaults since PG 10 already allow replication (
wal_level = replica, 10 WAL senders, 10 slots), so you mostly add a limit for slots and a login for replicas.INI# /etc/postgresql/16/main/postgresql.conf on the PRIMARY listen_addresses = '10.0.1.10' # private address only wal_level = replica # default; 'logical' if you also need step 6 max_wal_senders = 10 # default; one per replica plus spare max_replication_slots = 10 # default max_slot_wal_keep_size = '50GB' # a dead replica's slot may not fill the diskSQL-- On the primary: a role that may only replicate CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'change-me';Output# /etc/postgresql/16/main/pg_hba.conf on the PRIMARY # TYPE DATABASE USER ADDRESS METHOD host replication replicator 10.0.1.11/32 scram-sha-256Reload with
sudo systemctl reload postgresql. A change towal_level,max_wal_sendersormax_replication_slotsneeds a restart instead. -
Clone the primary with
pg_basebackup -ROn the new replica machine only.
-Rwrites the filestandby.signaland the connection settings, so the copy starts as a replica.-C -S replica1creates a replication slot, so the primary keeps the WAL this replica still needs.Terminal# On the NEW REPLICA machine (10.0.1.11) sudo systemctl stop postgresql sudo -u postgres mv /var/lib/postgresql/16/main /var/lib/postgresql/16/main.empty sudo -u postgres pg_basebackup \ -h 10.0.1.10 -U replicator \ -D /var/lib/postgresql/16/main \ -R -X stream -C -S replica1 -P # prompts for the replicator password # Give the replica a name so it shows up by name on the primary echo "cluster_name = 'replica1'" | sudo tee -a /etc/postgresql/16/main/postgresql.conf sudo systemctl start postgresql sudo -u postgres ls /var/lib/postgresql/16/main | grep -E 'signal|auto'Outputpostgresql.auto.conf <- now holds primary_conninfo and primary_slot_name standby.signal <- "start as a replica" -
Check the replica from both sides
On the primary,
pg_stat_replicationhas one row per connected replica. The output below was captured on a test pair under a write benchmark, with the replica deliberately delayed by 3 seconds.SQL-- On the PRIMARY SELECT application_name AS replica, state, sync_state, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS bytes_behind, write_lag, flush_lag, replay_lag FROM pg_stat_replication;Outputreplica | state | sync_state | bytes_behind | write_lag | flush_lag | replay_lag ----------+-----------+------------+--------------+-----------------+-----------------+----------------- replica1 | streaming | async | 2802 kB | 00:00:00.000804 | 00:00:00.000804 | 00:00:02.998796Read it like this: the WAL reached the replica's disk in under a millisecond, but replay is 3 seconds behind. The
*_lagcolumns describe recent WAL only, and go NULL a while after the primary goes idle.SQL-- On the REPLICA SELECT pg_is_in_recovery() AS is_replica, CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN interval '0' -- caught up: nothing left to replay ELSE now() - pg_last_xact_replay_timestamp() END AS replay_delay;Keep the CASE. Without it, the delay keeps growing whenever the primary has no new writes, say at 4 am, and pages you for lag that does not exist.
-
Watch the slots
A slot whose replica is gone keeps WAL on the primary until the disk fills and the primary stops. Alert on this, and drop slots you no longer need.
SQLSELECT slot_name, slot_type, active, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS wal_kept FROM pg_replication_slots; -- Only after you are sure nobody uses it: -- SELECT pg_drop_replication_slot('old_replica');Good alert starting points:
replay_lagabove 30 seconds for 5 minutes, any slot that is inactive for more than an hour, andwal_statusother thanreserved. -
Route reads in NestJS with Prisma
Install
@prisma/extension-read-replicas. Version 0.4.1 targets Prisma 6, 0.3.x and 0.4.0 target Prisma 5, and 0.5 needs Prisma 7, which configures replicas differently. Reads outside a transaction go to the replica. Writes,$transactionand raw SQL go to the primary.$primary()forces a read to the primary.TypeScript// db.module.ts import { Global, Module } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { readReplicas } from '@prisma/extension-read-replicas'; export const DB = 'DB'; const createDb = () => new PrismaClient().$extends( readReplicas({ url: process.env.DATABASE_REPLICA_URL! }), // an array also works ); export type Db = ReturnType<typeof createDb>; @Global() @Module({ providers: [{ provide: DB, useFactory: createDb }], exports: [DB] }) export class DbModule {}TypeScript// grn.service.ts @Injectable() export class GrnService { constructor(@Inject(DB) private readonly db: Db) {} // Report: a few seconds old is fine, so this goes to the replica salesBySite(from: Date, to: Date) { return this.db.sale.groupBy({ by: ['siteId'], where: { saleDate: { gte: from, lt: to } }, _sum: { total: true }, }); } // The list shown right after a save: must be fresh, so force the primary recentGrns(siteId: bigint) { return this.db.$primary().grn.findMany({ where: { siteId }, orderBy: { receivedAt: 'desc' }, take: 20, }); } }Raw SQL always goes to the primary, because the extension cannot tell a read from a write. For a heavy raw report, opt in to the replica with
this.db.$replica().$queryRaw`...`. -
Optional: feed a reporting database with logical replication
This needs
wal_level = logicalon the primary (restart required). Create the same table on the reporting database first, because DDL is not copied.SQL-- On the PRIMARY CREATE PUBLICATION reporting_pub FOR TABLE grn, sale WITH (publish_via_partition_root = true); -- because sale is partitioned GRANT SELECT ON grn, sale TO replicator; -- needed for the first copy -- On the REPORTING database (tables already created) CREATE SUBSCRIPTION reporting_sub CONNECTION 'host=10.0.1.10 dbname=mangrove user=replicator password=change-me' PUBLICATION reporting_pub;Without the GRANT, the first copy never arrives, and the subscriber's log repeats
permission denied for table grn. -
Practise a failover on the test pair
Do this on test machines until the steps are boring. Then write them into the runbook.
Terminal# 1. Stop the old primary so it can never take writes again sudo systemctl stop postgresql # on 10.0.1.10 # 2. Promote the replica sudo -u postgres psql -c "SELECT pg_promote();" # on 10.0.1.11 sudo -u postgres psql -Atc "SELECT pg_is_in_recovery();" # f = it is a primary now # 3. Point DATABASE_URL at 10.0.1.11 (or move the DNS name) and restart the API
Bring it to your team
Ask these when someone proposes a replica, or when "the data didn't show up" bugs appear.
- "Which reads go to the replica and which stay on the primary? Can we see that decision per endpoint?"
- "What is the replica lag on a normal evening and at the festival peak, and which alert fires at what number?"
- "After a user saves, which screen do they see next, and where does that screen read from?"
- "If the primary dies at 8 pm, who promotes the replica, how long does it take, and how does the API find the new primary?"
- "What stops the old primary from accepting writes again after a failover?"
- "Is replication async or sync, and how much recent data could we lose in a failover?"
Red flags
- "We have replicas, so we don't need backups."
- Every read sent to the replica, including the screen shown right after a save.
- Synchronous replication to a single replica, and nobody knows that commits stop when it is down.
- A replication slot for a replica or tool that was removed months ago.
- A failover plan that has never been practised on a test system.
Practice
Decide where each read goes
Write routeRead(read, ctx) for the Store app's API. It returns 'primary' or 'replica'. A read has a kind ('auth', 'decides-write' or 'normal') and maxStaleMs, the most lag this screen can accept. ctx has nowMs, lastWriteMs (this user's last write, or null) and replicaLagMs (the measured lag, or null if unknown). The rules, in order:
- Auth reads and reads that decide a write always go to the primary.
- A user who wrote less than
STICKY_MS(5,000 ms) ago reads from the primary. - If the lag is unknown, or bigger than the screen's
maxStaleMs, use the primary. - Otherwise, use the replica.
const STICKY_MS = 5000; // a user's own write pins them to the primary this long
function routeRead(read, ctx) {
// read: { kind: 'auth' | 'decides-write' | 'normal', maxStaleMs }
// ctx: { nowMs, lastWriteMs (or null), replicaLagMs (or null) }
return 'replica';
}
const quiet = { nowMs: 100000, lastWriteMs: null, replicaLagMs: 300 };
const report = { kind: 'normal', maxStaleMs: 60000 };
test('a dashboard with fresh-enough data uses the replica', () => {
expect(routeRead(report, quiet)).toBe('replica');
});
test('login and stock checks always use the primary', () => {
expect(routeRead({ kind: 'auth', maxStaleMs: 60000 }, quiet)).toBe('primary');
expect(routeRead({ kind: 'decides-write', maxStaleMs: 60000 }, quiet)).toBe('primary');
});
test('the list right after your own save uses the primary', () => {
expect(routeRead(report, { ...quiet, lastWriteMs: 98000 })).toBe('primary');
});
test('a write 10 seconds ago no longer pins you to the primary', () => {
expect(routeRead(report, { ...quiet, lastWriteMs: 90000 })).toBe('replica');
});
test('too much lag, or unknown lag, sends the read to the primary', () => {
expect(routeRead({ kind: 'normal', maxStaleMs: 2000 }, { ...quiet, replicaLagMs: 3000 })).toBe('primary');
expect(routeRead(report, { ...quiet, replicaLagMs: null })).toBe('primary');
});
Show a solution
const STICKY_MS = 5000; // a user's own write pins them to the primary this long
function routeRead(read, ctx) {
if (read.kind === 'auth' || read.kind === 'decides-write') return 'primary';
if (ctx.lastWriteMs !== null && ctx.nowMs - ctx.lastWriteMs < STICKY_MS) return 'primary';
if (ctx.replicaLagMs === null || ctx.replicaLagMs > read.maxStaleMs) return 'primary';
return 'replica';
}Measure lag in bytes from two LSNs
An LSN such as 16/B374D848 is a 64-bit byte position in the WAL, written as two hexadecimal halves: the part before the slash is the high 32 bits, the part after it the low 32 bits. Write lagBytes(primaryLsn, replayLsn), which returns how many bytes of WAL the replica still has to replay, like pg_wal_lsn_diff does in SQL. Plain numbers are precise enough here.
function lsnToBytes(lsn) {
// '16/B374D848' -> high * 2^32 + low
}
function lagBytes(primaryLsn, replayLsn) {
// bytes the replica is behind
}
test('a replica that has caught up is 0 bytes behind', () => {
expect(lagBytes('0/3000060', '0/3000060')).toBe(0);
});
test('reads the low half as hexadecimal', () => {
expect(lagBytes('0/3000060', '0/3000000')).toBe(96);
});
test('the part before the slash counts 4 GiB per step', () => {
expect(lagBytes('1/0', '0/FFFFFF00')).toBe(256);
});
test('a bigger real-looking gap', () => {
expect(lagBytes('16/B374D848', '16/B3000000')).toBe(7657544);
});
Show a solution
function lsnToBytes(lsn) {
const [high, low] = lsn.split('/');
return parseInt(high, 16) * 2 ** 32 + parseInt(low, 16);
}
function lagBytes(primaryLsn, replayLsn) {
return lsnToBytes(primaryLsn) - lsnToBytes(replayLsn);
}Check yourself
The API writes to the primary and reads lists from an async replica. Sometimes a freshly saved GRN is missing from the list for a moment. What is the best fix?
Only the primary is sure to include the user's own write. With on, a commit waits for the replica to flush the WAL, not replay it, so the list can still miss the GRN, and every commit gets slower. A second replica or an index does not make a replica fresher.
At 10:42 someone runs DELETE FROM article; without a WHERE on the primary. You have two streaming replicas. What is the situation?
A replica replays every WAL record the primary writes, good or bad, usually within milliseconds, sync or async. Replicas protect against a dead machine, not a bad change. Recovering means restoring a backup to 10:41 (Backups and disaster recovery), or a delayed replica if you had one.
synchronous_standby_names = 'replica1' is set, and the replica1 server loses power. What happens to new commits on the primary?
Postgres keeps its promise that a commit exists on two machines, so each COMMIT waits with the wait event SyncRep. It never falls back to async by itself. That is why teams list two candidates with ANY 1 (replica1, replica2), or let a tool like Patroni manage the setting.
Flashcards
Key takeaways
- The primary takes every write. Replicas replay its WAL and serve reads. They do not add write capacity.
- Async is the default and keeps commits fast. Sync protects every commit, costs a round trip each time, and stops commits if the sync replica is down.
- Replicas are always somewhat behind. Keep reads that follow a user's own write, reads that decide a write, and security checks on the primary.
- Measure lag with
pg_stat_replicationon the primary and the replay timestamp on the replica, and watch replication slots. - Failover is promote plus fencing the old primary. Practise it. And remember that replicas copy mistakes, so you still need backups.