Skip to content
Software Atlas

Replication

Keeping copies of a database on several machines: leaders and followers, what gets shipped, sync vs async, replication lag and the bugs it causes, failover and split brain, multi-leader conflicts and quorums.

⏱ ~50 min read14 sections12 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceReplication means keeping a copy of the same data on several machines, so that losing one machine loses nothing, and so that more machines can answer questions.

Your app stores its data in a database: a program on some server whose job is to keep data safe and find it again. If that one server's disk dies, your data is gone. If it gets too busy, your whole app slows down. Replication fixes both by keeping full copies, called replicas, on other machines.

In the most common setup, one server is the leader (the teacher): it is the only one that accepts changes. The others are followers (the students): they copy every change the leader makes, and they answer read-only questions. Press the button a few times:

One leader, many copies
Press “like” to write. The leader changes first; each follower copies the change a moment later.
your appUPDATE likes…Leaderlikes = 0takes every writeFollower 1likes = 0copyFollower 2likes = 0copyFollower 3likes = 0copy · far away
No writes yet.

Three reasons to replicate

1

Survive machine loss

Disks fail, machines crash, whole data centers lose power. With a copy elsewhere, a follower can take over (that's failover, §08) instead of the app being down for hours.
2

Spread the reading

Most apps read far more than they write: often 10 or 100 reads per write. Followers can answer reads, so adding followers adds reading capacity.
3

Be close to users

Light takes about 70–80 ms to cross the Atlantic and back through fiber. A follower in Europe answers European reads in a few milliseconds instead.

The catch

Copying takes time. Usually only milliseconds, but sometimes seconds or minutes. During that time, the copies disagree, and a user who reads from the wrong one sees old data. This whole topic is about two questions:

  1. When should the leader say “saved”? Before or after the followers have the change? (§05)
  2. What do users see while the copies disagree, and how do you hide it? (§06–§07)

Then we'll handle the leader dying (§08), designs with several leaders or none at all (§09–§10), and running all this in production (§11).

Foundations§02

Database basics you need

In one sentenceA database records every change in a numbered diary, the write-ahead log, before it touches the data itself. Replication is mostly shipping that diary to other machines.

If you've used a database before, skim this section. If not, these are the only four ideas you need.

1. Tables, rows, and SQL

A relational database (one that stores data in tables) like Postgres stores data in tables, which look like spreadsheets: a users table has one row per user and columns like id and name. Each row has a primary key, a column such as id that is unique per row. You talk to the database in SQL:

SELECT name FROM users WHERE id = 42;                -- read
INSERT INTO users (id, name) VALUES (43, 'Bo');      -- add a row
UPDATE users SET name = 'Ana B.' WHERE id = 42;      -- change rows
DELETE FROM users WHERE id = 43;                     -- remove rows

The first statement only reads. The other three are writes: they change data. That split matters a lot here, because in the usual setup only one server may accept writes, while reads can go to any copy.

2. Commit: the moment a change becomes real

A change becomes official when it is committed. Until then the database can still undo it. Several statements can be grouped into one transaction that commits all together or not at all (the Transactions topic covers this properly). When the database answers “committed” it promises durability: the change will survive a crash or a power cut.

3. The write-ahead log

How can a database keep that promise quickly? Updating the right spots in big data files on every commit would be slow. Instead it first appends a short description of the change to the end of one file, the write-ahead log (WAL), and forces it onto the disk with fsync. Appending to the end of one file is fast. The data files are updated later, in the background.

A database's diary: the write-ahead log
Every change is appended to the log and saved to disk before the database says “committed”.
your appUPDATE users…#98INSERTusers 41 'Kim'#99UPDATEorders 7: paid#100DELETEcarts 3#101UPDATEusers 42 'Ana B.'→write-ahead log: append only, numbered, on disknewest changeapplied to the tables afterwardstables (data files)users · orders · cartscrash? replay the logto rebuild the tables

If the machine crashes, the database replays the log on restart and rebuilds whatever the data files were missing. Notice what the log gives us for free: an ordered, numbered list of every change ever made. Each entry has a position, and positions only go up.

4. Why this matters for replication

If another machine starts from the same data and replays the same log entries in the same order, it ends up with exactly the same data. That is the whole trick behind most replication: the leader writes its log as usual, and also streams it to the followers, which replay it. Postgres calls a position in the log an LSN (log sequence number); you'll see LSNs like 0/E041D58 in real output later.

Foundations§03

Leader and followers

In one sentenceAll writes go to one server, the leader. It streams its log of changes to the followers, which replay it in the same order, so they end up with the same data, a little later.

This setup goes by many names: leader/follower, primary/replica, primary/standby, and in older docs master/slave. They all mean the same shape:

  • The leader is the only server that accepts writes (INSERT, UPDATE, DELETE). For each change it appends an entry to its log, applies it, and sends the entry to every follower.
  • Each follower receives the entries and applies them in log order. It answers reads, but refuses writes.
  • The app sends writes to the leader and may send reads to any follower.

Why only one writer? Because then there's exactly one order of events. If two servers could both accept a change to the same row, they'd have to agree on which came first. One leader sidesteps that whole problem (§09 shows what happens when you don't).

Watch one write travel

How a write reaches the followers
A numbered log, shipped in order. Step through it with the buttons or ← →.
Code
1-- 1. on the leader
2UPDATE users SET name = 'Ana B.' WHERE id = 42;
3-- 2. on the leader
4INSERT INTO orders (id, user_id, total) VALUES (9, 42, 30);
5-- 3. on follower B
6SELECT name FROM users WHERE id = 42;
Leaderaccepts writes
position: 100
#100 …older changes
Follower Asame data center
position: 100
#100 …older changes
Follower Bother region
position: 100
#100 …older changes
Each server keeps a log: a numbered list of every change, in order. All three have applied everything up to change #100, so they hold identical data. Each follower only needs to remember one number: the position it has reached.
1 / 6

Two details from the stepper matter for everything that follows:

  1. The leader doesn't wait for the followers here. It replied OK at step 2, before anyone else had the change. That choice, called asynchronous replication, is the default almost everywhere. §05 looks at the alternative.
  2. A follower's whole state is one number: the log position it has replayed up to. That makes restarts easy (“send me everything after 102”), and it's how you'll measure lag (§06) and fix stale reads (§07).

Followers refuse writes

Here's what a real Postgres replica says when you try to write to it (this is from the test setup used throughout this page: a primary on port 5543 and replicas on 5544 and 5545):

$ psql -p 5544 -c "INSERT INTO users VALUES (43, 'Bo')"
ERROR:  cannot execute INSERT in a read-only transaction

So your app needs to know which server is the leader, and send writes there. §07 shows the code; §08 covers what happens when the leader changes.

How does a new follower start?

A brand-new follower can't replay the log from the very first change ever made; that log was deleted long ago. Instead:

  1. Take a base backup: a full copy of the leader's data files, made while the leader keeps running. Note the log position at which the copy started.
  2. Copy it to the new machine and start the follower.
  3. The follower asks the leader for every log entry after that position, replays them, and from then on keeps up with the live stream.

In Postgres, one command does steps 1–2 and writes the follower's configuration:

pg_basebackup -h primary.internal -U replicator -D /var/lib/postgresql/16/main -R -X stream

Here is the whole process, step by step:

Starting a new follower
A copy taken while the leader keeps writing, then catch-up from the log.
Leaderkeeps writing
log: … #500
Base backuppg_basebackup
nothing yet
New follower
not started
The leader is busy: its log is at #500 and still growing. We want a new follower on an empty machine.
1 / 5

A big database can take hours to copy this way. That's one reason teams keep spare followers around instead of building one only when they need it.

Practitioner§04

What gets shipped: statements, bytes, or rows

In one sentenceA leader can ship the SQL it ran, the bytes it changed on disk, or the rows it changed. Each choice trades size, safety, and flexibility.

In §03 the log entries looked like “users 42: name = 'Ana B.'”. Real systems pick one of three formats for those entries.

1. Statement-based: send the SQL

The simplest idea: the follower re-runs the same SQL the leader ran. This is statement-based replication. A statement that changes a million rows is still one short line to send. But anything whose result depends on when or where it runs gives a different answer on the follower, like now() and random(). So does anything that depends on the order statements run in, like auto-increment IDs when two inserts run at once. The follower quietly ends up with different data, and nothing reports an error. The widget at the end of this section lets you try it.

2. Physical: send the changed bytes

Physical replication ships the write-ahead log itself. Our diary in §02 was simplified: real Postgres WAL records don't say “user 42 got a new name”. They say which bytes changed in which disk page. Here is the real WAL for one UPDATE, decoded with the pg_waldump tool:

$ pg_waldump -p primary/pg_wal -s 0/D513198 -e 0/D513218
rmgr: Heap   len (rec/tot): 74/74, tx: 61092, lsn: 0/0D513198, desc: HOT_UPDATE
      old_off: 5, new_off: 7, blkref #0: rel 1663/5/16384 blk 0
rmgr: Transaction len (rec/tot): 46/46, tx: 61092, lsn: 0/0D5131E8, desc: COMMIT

(Some fields removed and lines wrapped for width.) Reading it: rmgr says which part of Postgres wrote the record (Heap = table data), tx is the transaction ID, and HOT_UPDATE is an update where the new row version stays in the same page. rel 1663/5/16384 blk 0 means “block 0 of the file with number 16384”, which is the users table on disk, and old_off/new_off are slot numbers inside that page. The follower patches the same bytes and becomes a byte-for-byte identical copy. Values like now() were computed once, on the leader, and are already inside those bytes.

The price: the follower must run the same major version of Postgres (16 with 16, not 16 with 15; minor updates like 16.2 → 16.4 are fine), and it copies everything: every table in every database on the server. You can't copy just one table, and no other kind of program can read the stream. This is what Postgres calls streaming replication, and it's what most Postgres replicas use.

3. Logical: send the changed rows

Logical replication sends one event per changed row, with the new values. Here is real Postgres output, captured with the built-in test_decoding plugin. First we create a logical replication slot (a bookmark that collects changes, §11) and run four statements:

SELECT * FROM pg_create_logical_replication_slot('demo', 'test_decoding');
UPDATE users SET name = 'Ana Lee' WHERE id = 42;
INSERT INTO users VALUES (7, 'Dee');
DELETE FROM users WHERE id = 100;
UPDATE users SET name = upper(name);   -- no WHERE: every row

Then we ask the slot what it collected:

$ psql -c "SELECT data FROM pg_logical_slot_get_changes('demo', NULL, NULL)"
 BEGIN 61088
 table public.users: UPDATE: id[integer]:42 name[text]:'Ana Lee'
 COMMIT 61088
 BEGIN 61089
 table public.users: INSERT: id[integer]:7 name[text]:'Dee'
 COMMIT 61089
 BEGIN 61090
 table public.users: DELETE: id[integer]:100
 COMMIT 61090
 BEGIN 61091
 table public.users: UPDATE: id[integer]:42 name[text]:'ANA LEE'
 table public.users: UPDATE: id[integer]:7 name[text]:'DEE'
 COMMIT 61091

Look at the last block: one statement, UPDATE users SET name = upper(name), became two row events, one per row in the table. Events carry final values, so now() and random() are safe. And because the format describes rows rather than disk bytes, you can copy only some tables, replicate between different Postgres versions (a common way to upgrade with little downtime), or feed the changes to a different system entirely (§11).

Try it: pick the middle statement (now()) with statement selected, then switch formats.

What does the leader actually send?
Pick a statement and a replication format. Watch whether the follower ends up with the same data.
now() = the current time on whichever server runs the statement.
Sent to the followerStatement-based
UPDATE users SET last_seen = now() WHERE id = 42;
Used by: old MySQL default; rare today
Result on the follower
✕ different from the leader
leader: last_seen = 11:40:33.024follower: last_seen = 11:40:33.031
The follower runs the statement a few milliseconds later, so now() gives a different time. The copies have quietly drifted apart, and nothing reports an error.
✓ tiny to send✕ safe with now()/random()✕ other systems can read it

Who uses what

SystemDefaultNotes
Postgres streaming replicationPhysical WALSame major version; whole server.
Postgres logical replicationLogical rowsBuilt in since Postgres 10: the leader publishes tables (CREATE PUBLICATION), the receiver subscribes (CREATE SUBSCRIPTION).
MySQLRow-based binlogbinlog_format can be STATEMENT, ROW or MIXED. ROW has been the default since MySQL 5.7.7; newer versions are phasing the setting out in favor of row-only.
MongoDBLogical (the “oplog”)Each entry describes one document change with final values.
Practitioner§05

Sync vs async: when does the leader say “saved”?

In one sentenceThe leader can say “saved” before the followers have the change (fast, may lose it) or after (safe, slower, and stuck if a follower is down). That one choice is the core trade-off of replication.

In §03 the leader replied OK as soon as the change was on its own disk. Then it shipped the change. If the leader's machine dies in between, the change exists nowhere else, even though your app already told the user “saved”. That's the risk of asynchronous replication.

The alternative is synchronous replication: the leader waits until a follower confirms “I have it” before replying. Now a confirmed write is on two machines. But every write pays for a network round trip, and the leader can only go as fast as the follower it's waiting for.

What the app waits for

Time from “commit” to “OK” (illustrative, not to scale)
async
disk
semi-sync
disk
→ nearest → back
sync (all)
disk
→ F2 in another region → back
Async waits only for the leader's own disk. Sync waits for the slowest follower it must hear from; semi-sync for the fastest one.

The widget below runs those three modes. Do this:

  1. In async, press write two or three times, then crash the leader quickly (within about half a second). Writes the app already saw as “OK” get lost.
  2. Switch to sync (all) and repeat. Nothing confirmed is lost, but each write waits for the faraway follower.
  3. Tick follower 2 unreachable in sync mode and write. The app waits forever.
  4. Try semi-sync (any 1) with follower 2 unreachable: writes still succeed, through follower 1.
When does the leader say “OK”?
Send a few writes, then crash the leader. Compare how long the app waits, and what survives.
your appidleLeaderwrites: 0Follower 1writes: 040 ms awayFollower 2writes: 090 ms away (far region)changeconfirmation (“I have it”)
The leader says OK as soon as the write is on its own disk. Fast, but a write can be acknowledged and then lost if the leader dies before shipping it.

Real numbers

On the test machine (primary and replica on the same computer, so the network costs almost nothing), one client committing small writes in a loop measured roughly:

ModeAverage time per commit
Async~1.0–1.5 ms
Sync, wait for the replica~2–3 ms

Even with zero distance, waiting for a second machine to write to its disk about doubled commit time. Across regions, add the network round trip to every commit: roughly 10–20 ms between nearby regions, 70–150 ms across an ocean (approximate; depends on the route). A request that makes five writes one after another would pay that five times.

A dead sync follower stops all writes

Sync mode waits for the follower forever by default. Here is what happened when the only sync replica was stopped and a Node app (with the pg driver) inserted one row. After 5 s, a second connection ended the wait by calling pg_cancel_backend(pid), Postgres's “cancel that session's current query” function:

still waiting after 5006 ms; cancelling…
WARNING:  canceling wait for synchronous replication due to user request
DETAIL:  The transaction has already committed locally, but might not have been replicated to the standby.
INSERT returned after 5017 ms

Read the DETAIL line carefully. The INSERT succeeded, with only a warning: the row is committed on the leader, it just isn't on any follower. Both ways out of this wait are dangerous:

  • If the wait is cancelled (as here), the app gets “success” and believes the write is safely on two machines. It isn't.
  • If the app gives up first (a Go context deadline, a driver timeout), the app sees an error, but the row is committed anyway. Retrying may insert it twice.
  • statement_timeout doesn't help. On the test setup, SET statement_timeout = '2s' did not end this wait; the INSERT was still hanging after 8 s. The wait for replication isn't counted as part of running the statement.

The real fix is not to get here: never make writes depend on one follower (next).

The usual answer: semi-sync

The common compromise is semi-synchronous replication: have two or more followers, and wait for any one of them. You pay the round trip to your nearest follower, a confirmed write is always on at least two machines, and one dead follower doesn't stop anything. In Postgres:

ALTER SYSTEM SET synchronous_standby_names = 'ANY 1 (replica_a, replica_b)';
SELECT pg_reload_conf();

Postgres also lets each transaction choose how much to wait for, with the synchronous_commit setting:

synchronous_commitThe leader says OK when…Can lose a confirmed write if…
offit has written the change in memory (not even its own disk yet)the leader crashes
localthe change is on the leader's diskthe leader's machine is lost
remote_writethe follower has received it and handed it to its operating system (not yet on its disk)the leader dies and the follower loses power
on (default)the follower has it on diskboth disks are lost
remote_applythe follower has applied it, so reads there see itboth disks are lost

The same thing as a picture. Move the “OK” flag with the setting, then pick a crash:

Where does “OK” come back?
Pick a synchronous_commit setting, then pick a crash. Greyed stages are destroyed by the crash.
leader memory
WAL buffer
→
leader disk
WAL fsynced
→
network
on the wire
→
follower OS
received, not on disk
→
follower disk
fsynced
→
follower applied
visible to queries
▲
“OK” returned here
OK once the change is on the leader's disk. This is plain async replication.

With no synchronous_standby_names configured, on, remote_write and remote_apply all behave like local: plain async. remote_apply is interesting for §06: once it returns, a read from that follower is guaranteed to see the write.

Practitioner§06

Replication lag and what users see

In one sentenceReplication lag is how far a follower is behind the leader. It's usually milliseconds, but even milliseconds are enough for users to see their own changes vanish.

With async replication, every follower is always at least a little behind. Reading from a follower gives you eventual consistency: if writes stopped, all copies would end up the same, but right now they might differ. The gap is replication lag, measured in time (“0.8 s behind”) or in log bytes (“1,776 bytes behind”).

How big is it?

On a quiet system on a fast network: well under a millisecond. That sounds harmless. So here's a real experiment from the test setup: update a row on the primary, then immediately read it back from a replica on the same machine, 1,000 times:

stale.mjs
// primary, replica: two pg.Pool objects, one per server (shown in §07)
let stale = 0;
for (let i = 1; i <= 1000; i++) {
  await primary.query("UPDATE users SET name = $1 WHERE id = 42", [`v${i}`]);
  const { rows } = await replica.query("SELECT name FROM users WHERE id = 42");
  if (rows[0].name !== `v${i}`) stale++; // ← the replica hasn't applied it yet
}
$ node stale.mjs
723 of 1000 reads right after a write were stale
$ node stale.mjs
784 of 1000 reads right after a write were stale

Three out of four reads saw the old value, with no network distance at all. The app's read simply arrived faster than the change. Any “save, then reload the page” flow that reads from a replica will hit this.

And lag isn't always small. It grows when:

  • The leader writes faster than a follower can replay. A big batch job or a migration that rewrites a whole table can put followers minutes behind.
  • A follower is busy or slow. It might have weaker hardware, or be serving heavy read queries. Replay in Postgres runs in a single process, so one slow step holds up everything after it.
  • The network is slow or broken. A follower in another region is at least one trip across the ocean behind, and a network cut makes the lag grow without limit until it heals.
  • A follower is paused on purpose. Long queries on a replica can make replay wait (§11).

Three anomalies

When reads go to followers, lag shows up as three kinds of “that can't be right” bugs. Each has a name, because each needs a different fix. Step through them:

Lag anomaly: read your own writes
Pick an anomaly above, then step through it. Switch to “with the fix” to see the cure.
Leaderwrites
name: Ana
Replica A~0 s behind
name: Ana
Replica B2 s behind
name: Ana
Ana's screen
Profile: Ana
Ana opens her profile. All three servers agree her name is “Ana”.
1 / 5
AnomalyWhat the user seesThe promise that's brokenTypical fix
Read-your-writes“I saved it and it's gone.”Read-your-writes: you see your own changes.Read from the leader after you write, or use a position token (§07).
Monotonic reads“The comment appeared, then disappeared.”Monotonic reads: never see older data than you already saw.Keep each user on one replica (§07).
Consistent prefix“An answer before the question.”Consistent prefix: see related writes in order.Keep related data in one partition (one log); see the widget above.

Notice that none of these require anything to be broken. No crash, no bug in the database. They are the normal behavior of async replicas, and the database won't hide them from you. Your application has to.

Practitioner§07

Routing reads and reading your writes, in code

In one sentenceSend writes to the leader and most reads to replicas. For reads that must include the user's own recent writes, remember how far the leader had got when they wrote, and only use a replica that has caught up that far.

Step 1: two pools

The app keeps two connection pools: one to the leader for writes, one to the replicas for reads. (A pool is a set of open database connections the app reuses, so it doesn't reconnect for every query.) Every query has to be sent to one or the other on purpose.

db.go
import (
	"database/sql"

	_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver
)

type DB struct {
	primary *sql.DB // all writes
	replica *sql.DB // reads that can tolerate a little lag
}

func Open(primaryURL, replicaURL string) (*DB, error) {
	primary, err := sql.Open("pgx", primaryURL)
	if err != nil {
		return nil, err
	}
	replica, err := sql.Open("pgx", replicaURL)
	if err != nil {
		return nil, err
	}
	return &DB{primary: primary, replica: replica}, nil
}

Step 2: a read-your-writes helper

The idea, from the “with the fix” demo in §06:

  1. After a write, ask the leader for its current log position, its LSN, and keep it in the user's session (for example, a cookie). Call it the user's token.
  2. Before a read that should include that user's writes, ask the replica: “have you replayed up to the token yet?” If yes, read there. If not, send this read to the leader.

Postgres gives us both numbers: pg_current_wal_lsn() on the leader, and pg_last_wal_replay_lsn() on a replica.

ryw.go
// Write runs a statement on the primary and returns the log position (LSN)
// the primary had reached afterwards: the user's "token".
func (d *DB) Write(ctx context.Context, q string, args ...any) (string, error) {
	if _, err := d.primary.ExecContext(ctx, q, args...); err != nil {
		return "", err
	}
	var lsn string
	err := d.primary.QueryRowContext(ctx, "SELECT pg_current_wal_lsn()::text").Scan(&lsn)
	return lsn, err
}

// QueryRowAfter reads from the replica only if it has replayed at least up to
// lsn; otherwise it asks the primary. It also says which one it used.
func (d *DB) QueryRowAfter(ctx context.Context, lsn, q string, args ...any) (*sql.Row, string) {
	if lsn != "" {
		var caughtUp sql.NullBool // NULL on a server that isn't (and wasn't) a replica
		err := d.replica.QueryRowContext(ctx,
			"SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn", lsn).Scan(&caughtUp)
		if err != nil || !caughtUp.Bool {
			return d.primary.QueryRowContext(ctx, q, args...), "primary" // ← fall back to the leader
		}
	}
	return d.replica.QueryRowContext(ctx, q, args...), "replica"
}

To see the helper work, the test replica on port 5545 was made to lag on purpose (recovery_min_apply_delay = '2s' holds each change back 2 s). A short script then wrote a new name and read it back with each helper. Real output:

$ go run .
after 0s: Ana G. (from primary)
after 2.5s: Ana G. (from replica)

The naive read straight from the replica returned the old name. The helper noticed the replica was behind the token and asked the primary. Once the replica caught up, the same token sent reads back to the replica.

Other fixes, simplest first

  • Read from the leader for a while after any write. Store lastWriteAt in the session; for, say, 10 seconds afterwards, send that user's reads to the leader. No LSNs needed. Pick the window bigger than your normal lag, and accept that a lag spike beyond it still shows stale data.
  • Read from the leader for anything the user can edit. Their own profile and settings come from the leader; everybody else's come from replicas. Simple, and covers the common “I saved it and it's gone” case.
  • Wait a little instead of falling back. If the replica is behind the token, re-check every few milliseconds for up to, say, 50 ms before giving up and asking the leader. That protects the leader during a burst of writes.
  • Sticky replicas for monotonic reads (§06): pick the replica with hash(userId) % replicas.length, so one user always reads the same replica.
  • Make the write wait instead. SET synchronous_commit = remote_apply for one transaction (§05) makes the commit return only after the sync follower has applied it. Reads from that follower are then fresh, at the cost of a slower write.

Measuring lag yourself

On a replica, three questions answer “how far behind am I?”:

SELECT pg_is_in_recovery()          AS is_replica,     -- true on a follower
       pg_last_wal_receive_lsn()    AS received_up_to, -- arrived over the network
       pg_last_wal_replay_lsn()     AS replayed_up_to, -- applied, visible to queries
       now() - pg_last_xact_replay_timestamp() AS behind_by;

behind_by is the time since the last transaction this replica replayed was committed on the leader. It's the easiest number to read, with one trap: if the leader is idle, nothing new arrives, so behind_by grows even though the replica is fully caught up. On the leader, pg_stat_replication shows every follower's position and lag at once; §11 shows real output and what to alert on.

Practitioner§08

Failover: when the leader dies

In one sentenceFailover is four steps: notice the leader is gone, choose the most up-to-date follower, promote it, and re-point everyone. Each step can go wrong, and the worst failure is ending up with two leaders.

When a follower dies, not much happens: send its reads elsewhere, and when it comes back it asks for the log from where it left off (§03). When the leader dies, nobody can write. failover means turning a follower into the new leader. You can do it by hand (an engineer gets paged and runs a command) or let a tool do it automatically.

The four steps

  1. Notice. A monitor checks the leader with heartbeats, small “are you alive?” messages every second or so. A dead machine and a slow or unreachable one look the same from outside, so the only tool is a timeout: “no answer for 10 s means dead”. Load balancers face exactly the same choice; see health checks.
  2. Choose. Pick the follower that is furthest ahead in the log, so the fewest writes are lost.
  3. Promote. Tell it to stop following and start accepting writes: promotion. In Postgres that's one function call, SELECT pg_promote();.
  4. Re-point. Other followers must follow the new leader, and apps must send writes to it.
Failover, step by step
Pick a story: a clean failover, split brain, or the fenced version.
Node Aleader
👑 leader (term 1)
log up to #101
Node Bfollower
follower
log up to #101
Node Cfollower
follower
log up to #100
Monitor & apps
♥ heartbeats from A: ok
apps → A
Normal life: A is the leader; B and C follow. A monitor (a separate program, such as Patroni) checks every second that A is alive: a heartbeat.
1 / 6

Lost writes

In the first story, write #102 was confirmed to the app but existed only on the old leader. With async replication, failover always risks this: you lose whatever the old leader hadn't shipped yet, usually less than a second of writes. Picking the most up-to-date follower keeps the loss small; only sync or semi-sync replication (§05) makes it zero.

Lost writes can do damage beyond the missing rows. A famous GitHub incident (2012) involved a promoted MySQL follower that was behind: auto-increment IDs that the old leader had already handed out were handed out again by the new one. A cache in another system (Redis) still held the old meaning of those IDs, so some users saw other users' private data.

Split brain, for real

In the second story the old leader wasn't dead, only cut off, and kept accepting writes. Here's that situation reproduced with the test setup: promote the replica while the primary is still running, then write a different value to each.

$ psql -p 5544 -c "SELECT pg_promote()"     # make the replica a leader
 pg_promote
------------
 t
$ psql -p 5543 -c "UPDATE users SET name='written on OLD primary' WHERE id=42"
UPDATE 1
$ psql -p 5544 -c "UPDATE users SET name='written on NEW primary' WHERE id=42"
UPDATE 1

Both succeeded. Two leaders, two different truths. That is split brain. When the old primary was then told to become a follower of the new one, Postgres refused to stream from it:

LOG:  new timeline 2 forked off current database system timeline 1 before current recovery point 0/EA4D9B0

Each promotion starts a new timeline, a numbered branch of history. The old primary had written past the point where the branch split, so its log and the new leader's log disagree, and Postgres won't guess which one is right.

Two histories after split brain
Each promotion starts a new timeline. The old primary kept writing on timeline 1.
#98#99#100#101promotion pointB #102B #103B #104timeline 2new primary (B)A #102A #103timeline 1old primary (A) kept writing
Both servers share history up to #101. After that, B wrote on timeline 2 while A kept writing on timeline 1. Postgres won't stream timeline 2 into A, because A has already gone past the fork: new timeline 2 forked off current database system timeline 1 before current recovery point.

The tool pg_rewind can throw away the old primary's extra writes so it can rejoin; those writes are then gone unless someone copies them back by hand.

Fencing: one leader at a time

The fix is fencing: make sure the old leader cannot write before or while a new one takes over. Three common ways:

  • Power it off. STONITH (“shoot the other node in the head”): the monitor cuts the old leader's power or shuts it down through its management interface or a cloud API, and only then promotes.
  • Let it step down. The leader keeps a short lease (“I'm leader for the next 30 s”) in a coordination service (a small, highly available key-value store that several machines agree on) and must renew it. If it can't reach the service, it demotes itself before the lease expires, and the monitor waits until the lease has expired before promoting anyone else. Patroni works like this, with etcd, Consul or ZooKeeper as the coordination service.
  • Reject old leaders. Each new leader gets a higher number, called a term or epoch. (Postgres's timeline number plays a similar role inside Postgres, but other systems don't check it.) Storage or downstream services remember the highest number they've seen and reject writes carrying an older one: a fencing token.

Deciding “who is the leader now” safely, so that two nodes can never both believe it, is the problem that consensus algorithms solve. The Consensus & Raft topic shows how; tools like Patroni lean on such a system instead of trusting their own timeouts.

Re-pointing the apps

The new leader is at a different address. Common ways apps find it:

MethodHow it worksWatch out for
DNS namedb-primary.internal is updated to the new leader's IP.Clients and resolvers cache answers for the record's TTL (see DNS TTL); some clients cache forever. Use a short TTL.
Virtual IPThe leader's IP address moves to the new machine.Only works within one network; needs fencing so two machines don't claim it.
ProxyA proxy such as HAProxy checks each node (for example, by asking Patroni) and sends traffic to the current leader.The proxy itself needs to be redundant (see Load balancing).
Client finds itThe driver gets a list of hosts and tries each until one accepts writes.Must be supported by your driver.

The last option is built into Postgres's own client library: a connection string like host=db1,db2,db3 target_session_attrs=read-write makes the client try each host and keep the one that accepts writes. Recent pgx versions (Go) support it too; check what your driver supports.

Whichever you choose, apps must expect connection errors during failover and retry, with backoff, for the 10–60 s it typically takes. Managed databases (for example AWS RDS Multi-AZ) do all four steps for you and typically fail over in about a minute; your app still needs the retry.

Staff-level§09

Multi-leader: writes in several places

In one sentenceLet several nodes accept writes and every write is fast and local. The price: two leaders can change the same thing at the same time, and something has to decide who wins.

With one leader, a user in Sydney writing to a leader in Virginia pays a round trip of roughly 200 ms on every write, and if the link between regions breaks, Sydney can't write at all. Multi-leader replication puts a leader in each region. Each one accepts local writes and replicates them asynchronously to the other leaders, which apply them like any other change.

Where you meet it

  • Multi-region apps that need fast writes everywhere, or must keep accepting writes when one region is cut off.
  • Offline-first apps. Every phone with a calendar app is effectively a leader: you add events on a plane, and they sync when you land.
  • Collaborative editing. In a shared document, every open browser tab accepts edits immediately and syncs with the others.

Most relational databases don't do this out of the box (for Postgres, extensions such as EDB's BDR / PGD add it); it's more common in systems designed for it, like CouchDB and sync engines in mobile apps. Leaderless systems such as Cassandra (§10) face the same conflicts.

Conflicts

With one leader, two edits of the same row happen one after the other, in log order. With two leaders, both can accept an edit before hearing about the other one. Neither is “later”; they happened concurrently. That's a write conflict, and it shows up long after both users were told “saved”, so you can't just return an error.

The most common rule is last write wins: stamp every write with the current time and keep the one with the bigger timestamp. It always converges, it's easy, and it's the default in several databases. It also silently throws away the other write. And “bigger timestamp” isn't the same as “later”, because different machines' clocks disagree: clock skew of a few milliseconds is normal even with time sync, and much more happens after a fault. Drag the US clock slider:

Two leaders, one value
The EU and US leaders accept a write to the same value at nearly the same time, then exchange their changes.
🇪🇺 EU leaderclock correct
“Q3 plan”
really written at 10:00:00.200
timestamp attached: 10:00:00.200
🇺🇸 US leaderclock correct
“Q3 roadmap”
really written at 10:00:00.350
timestamp attached: 10:00:00.350
after the leaders sync, both hold
“Q3 roadmap”
Both leaders keep “Q3 roadmap” because its timestamp is bigger. The EU user's edit is silently thrown away; nobody told them it failed.

Better ways to resolve

  • Avoid conflicts. Give each record a home leader: all writes to user 42's data go to user 42's home region. Conflicts only happen if the home changes. This is the most common practical answer.
  • Keep both. Store every concurrent version (often called siblings) and let the app or the user merge them, like a git merge conflict. To tell “concurrent” from “one after the other” without trusting clocks, systems attach a version vector: one counter per replica.
  • Use data types that merge. A CRDT (conflict-free replicated data type) is designed so concurrent changes always merge to the same result in any order: counters that add up each replica's increments, sets that remember both additions and removals, even text for collaborative editors (libraries such as Automerge and Yjs).
Staff-level§10

Leaderless: quorums (Dynamo style)

In one sentenceNo leader at all: send each write to all N copies and wait for W of them; read from R copies and keep the newest. If R + W > N, every read set overlaps every write set.

Leaderless replication was popularized by Amazon's Dynamo paper (2007) and is used by Cassandra, ScyllaDB and Riak. There's no leader to fail over: any replica accepts any request. With no single log to put writes in order, the client (or whichever node it talks to, acting as coordinator) sends each request to several replicas and counts answers.

N, W, R

Three numbers, the N, W, R settings, describe the setup:

  • N: how many copies of each value exist (often 3).
  • W: how many replicas must confirm a write before it counts as successful.
  • R: how many replicas a read asks; it keeps the newest version it gets back.

How does it know which is newest? Each write carries a version. In Cassandra that's a timestamp set by the client or coordinator, so “newest” inherits all the clock problems from §09; Riak uses version vectors instead.

Worked example: N = 3, W = 2, R = 2. A write reaches replicas 1 and 2; replica 3 is slow and misses it. A read asks two replicas. There are only three, so any two must include replica 1 or replica 2: at least one answer has the new value. 2 + 2 > 3, so the sets overlap. With R = 1 instead, the read might ask only replica 3 and get the old value. That's the R + W > N rule, and it's a quorum in action.

Quorum explorer
N copies of each value. A write waits for W of them to confirm; a read asks R of them and keeps the newest version. Click a replica to take it down.
R + W = 4 > N = 3: every read set overlaps every write set in at least one replica, so a read always reaches someone with the latest successful write.Writes keep working with up to 1 replica down; reads with up to 1.
Try: write with W = 2, then read with R = 1. Then set R = 2 and read again.

The same numbers decide availability. With N = 3, W = 2, R = 2, one replica can be down and both reads and writes still work. Choosing W = 3 makes reads cheap (R = 1 is enough to overlap) but means one dead replica blocks writes. Cassandra lets you pick per query with consistency levels such as ONE, QUORUM (a majority of N) and LOCAL_QUORUM (a majority within the local data center).

How replicas catch up

A replica that missed a write is stale until someone fixes it. Three mechanisms do that:

  • Read repair: when a read gets different versions from different replicas, the coordinator writes the newest back to the stale ones. Tick “read repair” in the widget and read again. Rarely read data is rarely repaired this way.
  • Hinted handoff: if a replica is down during a write, another node stores the write with a “hint” and hands it over when the replica comes back.
  • Anti-entropy: a background job compares replicas, using hash trees so it doesn't have to compare every row, and copies whatever is missing. (The widget's “background sync” button.)

Quorums are not magic

R + W > N sounds like it guarantees fresh reads. In practice there are gaps:

  • Concurrent writes to the same key still need conflict resolution, usually last write wins with its clock problems (§09).
  • A write that fails (fewer than W confirmations) isn't rolled back on the replicas that did take it. Later reads may or may not see it.
  • A write in progress may be visible to one read and not to the next, so even quorum reads can appear to go backwards.
  • A sloppy quorum lets stand-in nodes accept writes for down replicas (via hinted handoff) and count toward W. Good for availability, but the read set and write set may no longer overlap.

So leaderless systems with quorums are best thought of as eventually consistent with good odds, not as behaving like one copy. That stronger guarantee is called linearizability, and getting it across machines needs more machinery; the CAP & PACELC topic and Consensus & Raft pick up from here.

Staff-level§11

Running replicas in production

In one sentenceReplication protects you from machines failing, not from people making mistakes. In production, most replication pain comes from lag nobody watched, disks filling with unread log, and long queries on replicas.

Replicas are not backups

A replica copies every change, including the bad ones. Here's a DELETE with a forgotten WHERE, on the test setup:

$ psql -p 5543 -c "DELETE FROM users"          # oops: no WHERE clause
DELETE 2
$ psql -p 5544 -c "SELECT count(*) FROM users"  # the replica, a moment later
 count
-------
     0

Within milliseconds the mistake was on every replica. The same goes for a buggy migration, an app bug that overwrites data, or an attacker with write access. You still need real backups:

  • Point-in-time recovery. Take regular full backups and archive the WAL continuously (tools: pgBackRest, WAL-G, Barman, or your cloud's managed backups). Then point-in-time recovery can restore to “one second before the DELETE”.
  • A delayed replica. A delayed replica applies changes, say, one hour late (recovery_min_apply_delay = '1h'). If someone notices the mistake within the hour, you pause that replica and copy the lost rows back from it.
  • Test restores. A backup you've never restored is a hope, not a backup.

Watch the lag

On the leader, pg_stat_replication has one row per connected follower. Real output while a benchmark was writing; replica r2 was set to apply changes 2 s late:

$ psql -p 5543 -c "SELECT application_name AS replica, state, sync_state,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS behind,
    write_lag, flush_lag, replay_lag FROM pg_stat_replication"
 replica |   state   | sync_state |   behind   |    write_lag    |    flush_lag    |   replay_lag
---------+-----------+------------+------------+-----------------+-----------------+-----------------
 r1      | streaming | async      | 2576 bytes | 00:00:00.000201 | 00:00:00.000589 | 00:00:00.001147
 r2      | streaming | async      | 2530 kB    | 00:00:00.00014  | 00:00:00.000499 | 00:00:01.99979

The three lag columns follow a change along its path: received and written by the follower (write_lag), safely on its disk (flush_lag), and applied so queries can see it (replay_lag). For r2, the first two are tiny and replay_lag is 2 s: the network is fine, the replica is just holding changes back. When replay_lag is high but write_lag is low, look at the replica (busy disk, long queries); when all three are high, look at the network. These are the same stages as the commit pipeline in §05:

Where each lag column is measured
One change travels left to right. pg_stat_replication times each stage (numbers: replica r2 above).
leader memory
WAL buffer
→
leader disk
WAL fsynced
→
network
on the wire
→
follower OS
received, not on disk
→
follower disk
fsynced
→
follower applied
visible to queries
write_lag0.14 ms
flush_lag0.50 ms
replay_lag2.0 s

What to alert on (starting points to tune, not rules):

  • Replay lag above what your app tolerates, e.g. more than 30 s for 5 minutes. Pair it with automatic routing: take a replica out of the read pool while it's more than a few seconds behind, the way a load balancer drops a failing backend.
  • A follower missing from pg_stat_replication, or not in state streaming.
  • WAL kept for replication slots (next).

Replication slots can fill the disk

A replication slot is a bookmark on the leader that says “this follower has read up to here; keep all WAL after it”. It means a follower that disconnects for a while can resume instead of needing a new base backup. But the leader keeps that WAL forever if the follower never comes back. Real output after creating a slot for a replica that was then switched off, and running a data load:

$ psql -p 5543 -c "SELECT slot_name, active,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS wal_kept
    FROM pg_replication_slots"
       slot_name       | active | wal_kept
-----------------------+--------+----------
 r1_slot               | t      | 6063 kB
 r2_slot               | t      | 6063 kB
 old_analytics_replica | f      | 262 MB

262 MB after one small load; a busy database writes that every few minutes. A forgotten slot (an old analytics replica, a CDC tool someone uninstalled) is a classic cause of a leader's disk filling up and the leader going down. Alert on inactive slots and on wal_kept, and set max_slot_wal_keep_size so Postgres gives up on a slot instead of the disk.

Long queries on replicas

Replicas are a great place for heavy reports and analytics, because those queries can't slow down the leader. But they collide with cleanup. When a row is updated or deleted, Postgres keeps the old version around for a while, in case a running query still needs it; VACUUM later removes old versions nobody needs. The leader's VACUUM doesn't know about queries on the replica, and its cleanup arrives through the WAL like any other change. So the replica has a choice: pause replay (lag grows) or cancel the query. By default it waits up to max_standby_streaming_delay (30 s), then cancels. With that set to 3 s, a long query on the test replica got:

ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.
Time: 5086.106 ms (00:05.086)

Turning on hot_standby_feedback makes the replica tell the leader which old versions its queries still need. The same query then finished, and VACUUM on the leader reported:

tuples: 0 removed, 50000 remain, 16667 are dead but not yet removable

In that output, “tuples” are row versions and “dead” ones are old versions left behind by the DELETE: dead rows. No more cancellations, but the leader now keeps dead rows for as long as the longest replica query runs: a six-hour report means six hours of table bloat on the leader. The usual setup: a dedicated analytics replica with feedback on (or a long delay, accepting lag), never used for failover or for lag-sensitive app reads.

Topology and cost

  • Cascading replicas: a replica can feed other replicas. With five replicas in another region, the leader sends the WAL across the ocean once, to one replica there, which feeds the other four. Each extra hop adds lag.
  • Cross-region traffic costs money. Cloud providers bill data sent between regions, typically a few cents per GB (check your provider's current pricing). A leader writing 50 GB of WAL a day to three remote replicas sends 150 GB a day. Cascading and WAL compression help.
  • Failover across regions means the promoted replica is far from your app servers. Plan which replica may be promoted (synchronous_standby_names and failover-tool priorities), not just which exist.

Change data capture: replication for other systems

Logical replication (§04) produces a stream of row changes. Nothing says the receiver has to be a database. Change data capture (CDC) tools like Debezium read that stream (Postgres logical decoding, the MySQL binlog) and publish each change as an event, usually to Kafka. From there, other systems keep themselves in sync: a search index, a cache, a data warehouse, other services.

That beats writing to the database and to Kafka from your app, where one write can succeed while the other fails, leaving them inconsistent. With CDC, the database's log is the single source of truth. It pairs well with the outbox pattern: the app writes events to an outbox table in the same transaction, and CDC publishes them. Remember that a CDC tool holds a replication slot, with everything that implies.

Practitioner§12

Pitfalls

01

Reading from a replica right after writing

“Save, then redirect to the page that shows it” is the most common replication bug. Even on one machine, 3 in 4 immediate replica reads were stale (§06). Read the user's own data from the leader, or use a position token (§07).

02

Deciding on stale data, then writing

Checking stock on a replica (“5 left, fine”) and then writing the order to the leader is a check-then-act race with extra lag added. Anything you check in order to write must be read from the leader, ideally in the same transaction, with the protections from Race conditions (SELECT … FOR UPDATE, constraints).

03

Calling replicas a backup

A DELETE without a WHERE reaches every replica within milliseconds (§11). Keep real backups with point-in-time recovery, test restoring them, and consider a delayed replica.

04

Thinking “committed” means “on two machines”

With async replication (the default), a confirmed write can exist only on the leader. A failover loses it (§05, §08). If that's unacceptable, use semi-sync, and watch for MySQL semi-sync silently dropping to async after its timeout.

05

Sync replication to exactly one follower

Restart that follower for an upgrade and every write in the system hangs (real output in §05). Use ANY 1 (a, b) over two or more followers.

06

Automatic failover without fencing

A leader that is merely slow or cut off keeps accepting writes after a new one is promoted: split brain, with two forked histories (§08). Fence the old leader (power off, lease expiry, fencing tokens) before promoting, or use a tool built on consensus.

07

Apps that can't follow the leader

Long DNS TTLs, connection pools that never reconnect, no retries on connection errors: the database fails over in 30 s and the app stays broken for an hour. Use short TTLs or a proxy, retry with backoff, and rehearse failovers.

08

Forgotten replication slots

A slot whose consumer is gone makes the leader keep WAL forever, until the leader's disk fills up (§11). Alert on inactive slots and set max_slot_wal_keep_size.

09

Reports on the failover replica

Long analytics queries either get cancelled (“conflict with recovery”) or, with hot_standby_feedback, bloat the leader. On the replica you'd promote, they also add lag at exactly the wrong moment. Give analytics its own replica.

10

Trusting timestamps to order writes

Last write wins across machines silently discards one of two concurrent writes, and clock skew can make it discard the newer one (§09). Give records a home leader, keep both versions, or use CRDTs.

Practitioner§13

Check yourself

Predict the answer before you click. Q1 and Q8 come from §06–§07, Q2 from §05 and §08, Q3 from §05, Q4 from §10, Q5 from §04, Q6 from §11, and Q7 from §09.

QUESTION 1 / 8
Save, then show
await primary.query("UPDATE users SET name = $1 WHERE id = $2", ["Ana B.", 42]);
const { rows } = await replica.query("SELECT name FROM users WHERE id = $1", [42]);
res.render("profile", { name: rows[0].name });
// async replication, replica in the same data center
QUESTION 2 / 8
Crash after OK
replication: async
t=0 ms    leader writes order #9 to its disk, replies OK to the app
t=50 ms   leader's machine loses power
          (order #9 hadn't been sent to any follower yet)
t=10 s    follower promoted
QUESTION 3 / 8
Restarting the sync follower
ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (r1)';
-- r1 is the only follower listed. Now r1 is restarted
-- for an upgrade, which takes 2 minutes.
QUESTION 4 / 8
Is this quorum safe?
N = 5 copies, W = 3, R = 2
QUESTION 5 / 8
Replaying the SQL
-- statement-based replication ships this text to the follower:
UPDATE users SET last_seen = now() WHERE id = 42;
QUESTION 6 / 8
Oops, no WHERE
DELETE FROM users;   -- run on the primary by mistake
-- 3 async replicas, a few milliseconds behind
QUESTION 7 / 8
Last write wins with a slow clock
EU leader (clock correct):   title = "Q3 plan"     at 10:00:00.200
US leader (clock 300 ms slow): title = "Q3 roadmap" at real time 10:00:00.350
Conflict rule: last write wins (biggest timestamp)
QUESTION 8 / 8
Comments that come and go
Reads are spread round-robin across replicas A and B.
B is ~3 s behind. A user refreshes a page repeatedly
and sees a new comment appear, vanish, and appear again.
Answered 0 / 8
Foundations§14

Cheat sheet

👑
One leader, many followersWrites go to the leader; followers replay its log in order and serve reads. A follower's state is one number: its log position.
📦
Ship results, not commandsPhysical WAL = byte copy, same version. Logical rows = flexible, feeds other systems. Statement-based breaks on now().
⏱️
Async: fast, can lose writesThe leader says OK before followers have it. A failover loses what wasn't shipped.
🤝
Semi-sync: the usual compromiseWait for any 1 of 2+ followers. Never sync to a single follower: one restart stops all writes.
🐢
Lag is normalEven on one machine, most reads right after a write were stale. Decide which reads may be stale.
🪪
Read your writesAfter a write, read from the leader, or carry the LSN and use a replica only once it has caught up.
🔪
Fence before you promoteTwo leaders = split brain = forked data. Power off, leases, or fencing tokens.
🧯
Replicas aren't backupsMistakes replicate in milliseconds. Keep PITR backups and test restores.
ProblemFix
“I saved it and it's gone”Read-your-writes: leader for own data, or LSN token (§07)
Data appears, then disappearsMonotonic reads: stick each user to one replica
Answer before the questionConsistent prefix: related writes in one partition
Can't lose a confirmed writesynchronous_standby_names = 'ANY 1 (a, b)'
Need fresh reads from one followersynchronous_commit = remote_apply for that write
Two regions both writingHome region per record; else keep both / CRDTs, not LWW
Leaderless stale readsR + W > N, read repair, anti-entropy
Replica queries cancelledDedicated analytics replica, hot_standby_feedback
Leader disk filling upCheck pg_replication_slots; set max_slot_wal_keep_size
-- On the leader: who is following, and how far behind?
SELECT application_name, state, sync_state, replay_lag,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind
FROM pg_stat_replication;

-- On a replica: am I a replica, and how far have I got?
SELECT pg_is_in_recovery(), pg_last_wal_replay_lsn(),
       now() - pg_last_xact_replay_timestamp() AS behind_by;
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DatabasesPartitioning & Sharding
Read →