MySQL8 min read

MySQL Binlog Row Image and Size: Controlling Binlog Growth

Row-based binary logging is the right default, but it makes binlog volume a function of how many rows you touch. This covers FULL vs MINIMAL row images, partial JSON logging, expiry settings, and the big-UPDATE binlog explosion.

Row-based replication won the format argument years ago, and for good reasons: deterministic apply, no unsafe-statement edge cases, and a change stream that CDC tools can actually consume. The cost is arithmetic. In ROW format, binlog volume is proportional to rows touched times bytes per row image — not to the size of your SQL. A one-line UPDATE can write gigabytes of binlog, and every one of those bytes must be stored on the source, shipped over the network, written to relay logs, and applied on every replica.

Most fleets accept the defaults and are fine until the day they are suddenly not: a backfill fills the binlog volume, replicas fall an hour behind, and the point-in-time recovery window silently shrinks. This article covers the levers that control binlog size on MySQL 8.0 and 8.4 — row image, partial JSON logging, expiry — and the anatomy of the big-UPDATE incident so you can recognize it before it happens.

What a row image is, and what FULL costs

For every modified row, ROW-format binlog events carry row images: a before image identifying the row as it was, and an after image describing it as it became. With binlog_row_image=FULL — the default — both images contain every column of the row. Update one boolean flag on a table with a 2 KB average row, and the binlog records roughly 4 KB for that row: all columns before, all columns after. Multiply by a million rows and the flag flip costs about 4 GB of binlog, before compression.

FULL is the default because it is the most defensive setting: any consumer of the binlog gets complete rows, no matter what it knows about the schema. But it means binlog growth tracks row width, not change width, and wide tables — denormalized rows, big VARCHARs, JSON documents — pay the tax on every single-column update.

MINIMAL row image: the biggest safe lever, with conditions

binlog_row_image=MINIMAL logs only what is needed: the before image carries just enough columns to uniquely identify the row (the primary key, when one exists), and the after image carries only the columns the statement actually changed. For the flag-flip example, per-row cost drops from kilobytes to tens of bytes — routinely a 10x-100x reduction in binlog volume for wide-table workloads, depending entirely on how wide your rows are and how narrow your updates are. (There is also NOBLOB, a middle ground that logs full rows except unchanged BLOB and TEXT columns; it is rarely what you want when MINIMAL is available.)

The conditions matter. First, every replicated table needs a primary key — with MINIMAL images, a table without one forces the replica to match rows by full scans against whatever columns were logged, which is both slow and fragile. You can enforce this with sql_require_primary_key=ON, and you should. Second, the replica must be able to apply events that contain only changed columns; native MySQL replication handles this fine, but external binlog consumers are a different story. CDC pipelines — Debezium and friends — generally want FULL images, because a consumer that receives only changed columns has to reconstruct row state itself, and some downstream systems simply cannot. Point-in-time debugging also gets worse: with MINIMAL you can no longer read complete before-and-after rows out of the binlog when investigating what a bad deploy did to your data.

So the honest framing is: MINIMAL is a large, cheap win if native replication is your only binlog consumer, and a breaking change to evaluate carefully if the binlog feeds a data pipeline. Decide per fleet, deliberately.

Partial JSON logging

JSON columns deserve their own lever because they are often the widest thing in the row. By default, any update to a JSON document logs the entire document in the after image — a 100 KB document with one field changed costs 100 KB of after image. Setting binlog_row_value_options=PARTIAL_JSON logs a compact modification recipe instead, when the update was made with in-place functions like JSON_SET, JSON_REPLACE, or JSON_REMOVE. Assigning a whole new document still logs the whole document; the win applies only to in-place modification patterns. If you store large JSON and mutate it surgically, this setting plus MINIMAL row image is the difference between binlog volume tracking document size and tracking change size.

Retention: expiry is a promise about recovery

Binlog expiry on 8.0 and 8.4 is governed by binlog_expire_logs_seconds, defaulting to 2592000 seconds — 30 days. The pre-8.0 expire_logs_days variable is removed in 8.4, so upgrade-era configs that set only the old name silently get the default. Two things about expiry are commonly misunderstood.

Expiry is lazy: logs become eligible for purge at rotation or restart, not the moment they age out. And max_binlog_size (default 1 GiB) is a rotation threshold, not a ceiling — a transaction is never split across binlog files, so a 20 GB transaction produces at least a 20 GB binlog file regardless of the setting. Your retention window is also a functional promise: it bounds point-in-time recovery from your last full backup and bounds how long a stopped replica or CDC consumer can be down before it needs a rebuild — the same class of problem PostgreSQL solves, differently and with different failure modes, via replication slots, as covered in GTID vs replication slots.

Watch actual consumption, not just settings:

-- Inventory of binlogs on this server (file name, size, encrypted):
SHOW BINARY LOGS;

-- The settings that define your growth and retention posture:
SELECT @@GLOBAL.binlog_row_image AS row_image,
       @@GLOBAL.binlog_row_value_options AS row_value_options,
       @@GLOBAL.binlog_expire_logs_seconds / 86400 AS expire_days,
       @@GLOBAL.max_binlog_size / 1024 / 1024 AS rotate_mb,
       @@GLOBAL.sync_binlog AS sync_binlog;

MySQL offers no clean SQL aggregate over binlog file sizes, so have your metrics collector parse SHOW BINARY LOGS (or stat the binlog directory) and record total size and growth rate per hour. Trend it; the absolute number matters less than the derivative.

Anatomy of the big-UPDATE binlog explosion

The incident shape is so consistent it deserves a template. Someone runs a backfill — UPDATE orders SET new_column = computed_value on 200 million rows — as one statement, therefore one transaction. On a FULL-image table with 1 KB rows, that is roughly 400 GB of row images in a single transaction. The source's binlog volume fills, or comes close enough to trip pages. The transaction cannot rotate mid-write, so one binlog file balloons past max_binlog_size. Replicas receive the transaction for however long the network takes, showing near-zero lag the whole time, then begin a single-threaded apply that no amount of replica_parallel_workers can help, because one transaction cannot be parallelized. Semi-sync, if enabled, times out and degrades. If disk actually fills on the source, writes stop fleet-wide.

The fix is always the same and always applied one incident too late: batch large writes into bounded transactions.

-- Backfill in bounded transactions instead of one giant one:
UPDATE orders
SET new_column = computed_value
WHERE id BETWEEN 1 AND 50000
  AND new_column IS NULL;
-- commit, advance the id window, repeat; pause when
-- replica heartbeat lag exceeds your threshold.

Chunking caps per-transaction binlog volume, lets rotation and purge work, keeps replica appliers parallel, and gives you a natural throttle point tied to replica lag. Every serious online-schema-change and backfill tool works this way for exactly these reasons. The deeper structural reasons MySQL feels this harder than PostgreSQL — logical row events versus physical WAL pages — are laid out in binlog vs WAL.

A binlog monitoring baseline

Alert on binlog volume free space with generous headroom, because the failure is abrupt. Trend binlog bytes generated per hour and alert on multiples of baseline — that catches the runaway backfill while it is still killable. Alert on any single transaction whose binlog footprint exceeds a threshold you choose on purpose. And record row-image and expiry settings per fleet, because drift between a staging fleet on MINIMAL and production on FULL makes load tests lie about replication capacity.

Where MonPG fits

Plainly: MonPG is a PostgreSQL monitoring platform today. MySQL support is in active development — MySQL monitoring (coming soon) — and binlog growth rate, transaction-size outliers, and retention-window tracking are core to what we are building, because this incident class is preventable with unglamorous trend lines. Nothing here waits on us: SHOW BINARY LOGS and a disk metric get you most of the way. If PostgreSQL is also in your stack, you can start there today, where the WAL-side equivalents are already first-class.