Replication and WAL10 min read

MySQL Binlog vs PostgreSQL WAL: How Replication Really Differs

MySQL replicates logical change events from the binlog; PostgreSQL streams physical WAL. That one design difference changes lag measurement, consistency guarantees, and what your replicas can be.

When a team that has run MySQL for years starts evaluating PostgreSQL, replication is usually where the mental model breaks first. Both engines have a primary, both stream changes to replicas, both expose a lag number. It is tempting to assume the internals rhyme. They do not, and the differences are exactly the kind that surface during an incident at 2 a.m. rather than in a design review.

I have operated both. MySQL replication is mature, flexible, and battle-tested at enormous scale. PostgreSQL streaming replication is simpler, stricter, and harder to misuse. Neither is universally better, but they fail differently, they are measured differently, and they give replicas different capabilities. If you are planning a migration, you want those differences on paper before you cut over, not after.

Two different ideas of what "the log" is

MySQL replication is built on the binary log, a logical stream of change events that sits above the storage engine. InnoDB has its own redo log for crash recovery, but replication does not ship it. Instead, committed changes are written to the binlog as events, a replica's I/O thread pulls those events into a local relay log, and one or more applier threads execute them against the replica's own copy of the data. The replica is running its own storage engine, doing its own page writes, maintaining its own indexes.

PostgreSQL physical replication ships the write-ahead log itself. WAL is a block-level redo stream: page images and byte-range changes, not SQL and not row events. A walsender process on the primary streams WAL to a walreceiver on the standby, and the standby applies it with the same redo machinery it would use for crash recovery. The standby is not re-executing your workload. It is replaying the primary's disk writes, which makes it a byte-for-byte copy of the primary, extensions, bloat, and all.

This single design decision explains most of what follows. MySQL replicates what happened logically, so replicas can differ from the primary. PostgreSQL replicates what happened physically, so standbys cannot. (PostgreSQL also has logical replication when you need row-level selectivity; I cover that in the logical replication guide. Here I am comparing the default physical path most migrations land on.)

Binlog formats: row, statement, and mixed

Because the binlog is logical, MySQL has to decide how to describe a change, and it gives you three formats. Statement-based logging records the SQL text and replays it on the replica. Row-based logging records the before and after images of each affected row. Mixed logging uses statements where safe and switches to rows for nondeterministic cases.

Row format has been the default since MySQL 5.7 and is what almost everyone should run in 8.x, because statement-based replication has sharp edges: functions like NOW() or RAND(), UPDATE with LIMIT and no deterministic ORDER BY, and anything relying on side effects can produce different results on the replica. MySQL flags many unsafe statements, but "many" is not "all," and I have debugged silent drift caused by statement format more than once. Row format trades that risk for larger binlogs on wide updates, which binlog_row_image can partially mitigate.

PostgreSQL physical replication has no equivalent choice, and that absence is the feature. There is no format in which a standby can interpret a change differently from the primary, because the standby never interprets anything. If the primary wrote these bytes to this page, the standby writes the same bytes to the same page. Determinism is structural, not configured.

Measuring lag: Seconds_Behind_Source vs replay lag

Every MySQL operator eventually learns to distrust Seconds_Behind_Source (Seconds_Behind_Master before 8.0.22). It is computed from the difference between the replica's clock and the timestamp of the event the SQL thread is currently executing. That definition has famous failure modes: it reads 0 when the applier has consumed the relay log even if the I/O thread is far behind the primary, it goes NULL when replication threads stop, and a single long-running transaction makes it jump because event timestamps reflect when the statement started on the primary.

SHOW REPLICA STATUS\G
-- Watch these fields together, never Seconds_Behind_Source alone:
--   Replica_IO_Running / Replica_SQL_Running
--   Retrieved_Gtid_Set vs Executed_Gtid_Set
--   Seconds_Behind_Source

Serious MySQL shops supplement it with a heartbeat table written on the primary every second and read on the replica, which measures end-to-end visibility lag directly. Tools in the Percona ecosystem standardized this pattern years ago, and it remains the honest way to measure MySQL lag.

PostgreSQL gives you more precise primitives out of the box. The primary's pg_stat_replication view exposes, per standby, the last WAL position sent, written, flushed, and replayed, plus write_lag, flush_lag, and replay_lag as interval-typed columns measured from timestamps embedded in the stream.

SELECT application_name,
       state,
       sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_bytes_behind,
       write_lag,
       flush_lag,
       replay_lag
FROM pg_stat_replication;

The distinction between those three lag columns matters operationally. write_lag and flush_lag tell you how far behind durability is, which is what synchronous replication cares about. replay_lag tells you how stale reads on the standby are, which is what your application cares about. On the standby itself, pg_last_wal_replay_lsn() and pg_last_xact_replay_timestamp() let you measure staleness without asking the primary at all.

One PostgreSQL caveat in fairness to MySQL: a time-based lag calculation on an idle standby can misleadingly grow, because the last replayed transaction timestamp stops advancing when there is nothing to replay. Byte-based lag from LSN arithmetic does not have that problem, so alert on bytes and report seconds.

Consistency semantics: what a replica is allowed to be

Here the engines genuinely diverge in capability, and each direction has real value.

A MySQL replica is its own server executing a change stream. That means it can be writable (dangerous but occasionally useful), it can carry different indexes than the primary, it can use replication filters to skip databases or tables, and it can replicate between somewhat different versions during upgrades. The cost is that consistency is a discipline, not a guarantee. Writable replicas drift. Filters create partial copies that surprise the next engineer. Even with row format, a replica that was touched out-of-band will diverge silently until a replicated event finally collides with the difference and stops replication with an error.

A PostgreSQL physical standby is read-only, full stop. You cannot add an index only on the standby, you cannot filter tables, and major versions must match because the standby is replaying page-level changes. In exchange, divergence is off the table: the standby is the primary, a few moments ago. Failover promotes a physically identical copy of the cluster. When teams migrating from MySQL ask me where the replication filters are, my answer is that physical replication deliberately has none, and logical replication with publications is the tool when you actually need selective, cross-version, or writable-target replication.

Durability semantics also read differently. MySQL offers semi-synchronous replication, where a commit waits until at least one replica acknowledges receiving the event, with a timeout that silently degrades to asynchronous. PostgreSQL's synchronous_commit works with synchronous_standby_names and gives you graded levels, including remote_apply, which holds the commit until the standby has actually replayed it and the change is visible to standby reads. There is no automatic silent fallback; if the synchronous standby disappears, commits wait, which is stricter and occasionally more painful, but never quietly weaker than you configured.

Failure modes I have actually been paged for

On MySQL: replication stopped with a duplicate-key or row-not-found error because something wrote to the replica; Seconds_Behind_Source pinned at 0 while the I/O thread fell hours behind during a network incident; a schema migration that replayed slowly on a single-threaded applier while the primary, with all its concurrency, sprinted ahead. Multi-threaded appliers with WRITESET-based parallelism in 8.0 have improved that last one substantially, but replica apply throughput is still a tuning surface you own.

On PostgreSQL: a standby that fell behind and could not catch up because the primary had already recycled the WAL it needed, requiring a rebuild or a restore from archive, which is why wal_keep_size, WAL archiving, or replication slots exist; query conflicts on a hot standby, where long-running standby reads collide with replayed vacuum cleanup and either delay replay (hot_standby_feedback, max_standby_streaming_delay) or cancel the query; and replication slots retaining unbounded WAL for a dead standby, which is a disk-full incident in the making and deserves its own monitoring.

Neither list is damning. Both are the predictable consequences of each design, which is the point: you monitor for the failure modes your architecture makes possible.

What to watch after you land on PostgreSQL

If you are moving from MySQL, translate your replication runbook rather than discarding it. Your heartbeat-table habit maps to replay_lag and to standby-side timestamp checks. Your binlog disk monitoring maps to WAL volume and pg_wal directory size. Your "is the replica thread running" check maps to the presence and state of rows in pg_stat_replication. New items with no MySQL equivalent: replication slot retention, standby query conflicts, and the gap between flushed and replayed WAL under heavy write bursts. The PostgreSQL monitoring guide covers where each of these signals lives and what reasonable thresholds look like.

How MonPG helps once you are on PostgreSQL

MonPG monitors PostgreSQL only, so it will not watch your MySQL fleet during the migration. What it does is give the PostgreSQL side of the cutover the replication visibility this article argues for: per-standby write, flush, and replay lag from pg_stat_replication in both bytes and time, replication slot retention so a forgotten slot cannot quietly fill the disk, and WAL generation rate so a write burst that will stress your standbys is visible before the lag graph tells you it already did. It keeps that history alongside query and lock evidence, so "the replica fell behind at 14:02" and "this deploy tripled WAL volume at 14:00" appear in the same investigation. If PostgreSQL is where you are headed, PostgreSQL monitoring shows what that baseline looks like in practice.