MySQL11 min read

MySQL 8.0 JSON Partial Updates: Smaller Writes, Smaller Binlogs

Updating one key in an 18KB JSON document should not rewrite 18KB into the binlog. MySQL 8.0 can patch documents in place and log just the diff — when the change qualifies, and when it silently does not.

The week I learned about JSON partial updates, our binlog volume was growing by 40GB a day from a single table. The table held shopping carts: one JSON column averaging 18KB, and a checkout flow that touched one key at a time — a promo code here, a shipping flag there — maybe thirty times over a cart's life. Every one of those updates rewrote the entire document into the row event. Eighteen kilobytes of after-image, every time, to change twelve bytes. The replica never complained. The disk did, and so did the network team watching the cross-AZ traffic bill.

MySQL 8.0 has a two-part answer to this. InnoDB can update a JSON document in place when the change fits, so undo and redo shrink. And the binary log can carry just the diff instead of the full after-image, with binlog_row_value_options=PARTIAL_JSON, so binlog and replication traffic shrink. Neither mechanism is magic, both have precise conditions, and the fallback to full rewrites is completely silent. Here is what qualifies, what disqualifies you, how the binlog side interacts with the row image settings, and how I measured a six-times reduction on that carts table.

What does a partial JSON update do inside InnoDB?

For JSON_SET, JSON_REPLACE, and JSON_REMOVE, InnoDB can write only the changed bytes of the document in place instead of rewriting the whole value — as long as the updated document fits in the space the old one occupied. A JSON column is stored as a binary value, and a naive UPDATE reads the whole document, modifies it in memory, and writes a whole new version, with the entire old value copied to undo for rollback and MVCC. With a partial update, the server hands the storage engine a set of change descriptors — this path, this operation, this literal — and InnoDB patches the binary JSON where it lies. Undo shrinks from a full document copy to the small diff record, redo shrinks with it, and the update stops touching every byte of the document. Only those three functions participate, and the change must target existing paths: JSON_SET on a path that already exists, JSON_REPLACE, and JSON_REMOVE. On the carts table, the undo written per promo-code update dropped from roughly 18KB to well under 1KB, which mattered almost as much as the binlog savings because undo retention is what long transactions hold hostage.

How do you shrink the row events in the binary log?

Set binlog_row_value_options=PARTIAL_JSON, and row events for those same updates carry the JSON diff — the operation, the path, the new value — instead of the full after-image. With binlog_format=ROW, the after-image normally embeds the complete document, which is how one small key change becomes an 18KB event. With PARTIAL_JSON enabled, the event tells the replica to apply JSON_SET at a given path with a given value, and the replica applies that diff to its own copy. The variable is dynamic, so there is no restart — but it has both scopes, and SET GLOBAL only changes the default inherited by new sessions, so the session doing the updates needs its own:

-- log JSON diffs instead of full after-images (dynamic, no restart;
-- session scope reaches this session — SET GLOBAL would only hand it to new ones)
SET SESSION binlog_row_value_options = 'PARTIAL_JSON';

-- these three can stay partial when the new value fits the old footprint
UPDATE carts
SET items = JSON_SET(items, '$."promo"."code"', 'SAVE10')
WHERE cart_id = 48123;

UPDATE carts
SET items = JSON_REPLACE(items, '$."shipping"."expedite"', true)
WHERE cart_id = 48123;

UPDATE carts
SET items = JSON_REMOVE(items, '$."expired_coupon"')
WHERE cart_id = 48123;

-- this one rewrites the whole document: JSON_ARRAY_APPEND is not partial
UPDATE carts
SET items = JSON_ARRAY_APPEND(items, '$."audit"', JSON_OBJECT('at', NOW()))
WHERE cart_id = 48123;

You save binlog disk on the source, dump-thread bandwidth, relay-log writes, and apply I/O on every replica. The events are self-describing, so a replica applies them regardless of its own binlog_row_value_options setting — that variable only governs what the server writes when it originates events. You can confirm the events really are diffs by running mysqlbinlog over a recent file and looking for the partial JSON operations in the row events instead of full document literals.

When does MySQL silently fall back to a full rewrite?

Whenever the new value needs more space than the old one had, or whenever you use any JSON function outside the three-function allow-list — with no warning, no error, and no note in the slow query log. Growing the document beyond what fits in place forces a full rewrite: replacing a short string with a much longer one, or adding so many new paths that the binary encoding must expand. The function allow-list is the bigger trap: JSON_ARRAY_APPEND, JSON_ARRAY_INSERT, JSON_INSERT, JSON_MERGE_PATCH, JSON_MERGE_PRESERVE — all of them do a full read-modify-write, every time. The ORM pattern of merging a patch document into the stored one, which feels like it should be incremental, never gets a partial update. And the fallback being silent means the only evidence is your binlog staying fat. My rule is to check once, with mysqlbinlog on a captured sample, that the workload you think is partial actually is. I have been wrong about this exactly once, and it was the JSON_MERGE_PATCH case.

How does PARTIAL_JSON interact with binlog_row_image?

PARTIAL_JSON is independent of the row image: the diff mechanism works with FULL, MINIMAL, or NOBLOB alike, and the manual explicitly recommends pairing it with MINIMAL or NOBLOB for maximum savings. The row image governs everything around the JSON column. With binlog_row_image=FULL (the default), the before-image still carries the whole 18KB document while the after-image shrinks to the diff; with MINIMAL, the before-image shrinks to the key, and the changed JSON column still travels as a diff. I had the interaction backwards on first read and had to double-check on a test box: for a JSON-heavy table, MINIMAL plus PARTIAL_JSON is strictly less binlog than FULL plus PARTIAL_JSON, because only the FULL before-image keeps paying for the document. Teams who flipped to MINIMAL years ago to fight binlog growth get the JSON discount for free on top. The broader row-image trade-offs — replica crash safety, flashback tooling, what MINIMAL costs you operationally — are covered in binlog row image and size, and this is one more input to that decision.

How do you measure the binlog savings?

Drive a fixed update workload twice — once with full images, once with PARTIAL_JSON — and compare the bytes between binlog positions taken before and after each run. My measurement was simple: on an isolated replica of the carts schema, 50,000 promo-code updates against 18KB-average documents. With full images the run produced about 340MB of binlog; with PARTIAL_JSON it produced about 55MB. A bit over six times smaller, on a workload where each update changed a dozen bytes. Your ratio scales with the gap between document size and change size: a 300-byte preferences document gains almost nothing, a 100KB order document gains enormously. The snapshot method:

-- position before the workload (8.0 spelling; 8.2+ calls it SHOW BINARY LOG STATUS)
SHOW MASTER STATUS;

-- ... run the update loop ...

-- position after; the File/Position delta is your byte count as long as the
-- file did not rotate mid-run — if it did, sum the intervening files instead
SHOW MASTER STATUS;

-- per-update overhead on the source side shows up in the binlog itself:
-- run mysqlbinlog --base64-output=DECODE-ROWS --verbose over the captured
-- files and compare bytes per row event between the two runs

One deployment caveat that matters more than the benchmark: partial JSON events require a replica new enough to understand them. A 5.7 replica, or a very early 8.0, cannot apply these events, so the upgrade order is replicas first, source last — the same ordering discipline as any binlog-format change, and the same one the 5.7 to 8.0 upgrade notes hammer on.

Is partial update a reason to stuff more into JSON columns?

No — it removes the write-cost argument against large documents, not the indexing or querying arguments. Attributes you filter or join on still belong in real columns, or in generated columns with real indexes over JSON paths, which is the pattern in JSON indexing with generated columns; partial updates do nothing for a query that scans documents to find one key. Validation still lives in your application, CHECK constraints on JSON stay awkward, and a row whose SELECT pulls 40KB documents so the app can read two keys still pays that network and memory cost on every read. What changed is the calculus for genuinely schemaless payloads — event envelopes, feature bags, third-party webhook bodies — where the document is the honest shape of the data. For those, size your documents to the read pattern and stop worrying about the write pattern. The write pattern is cheap now, as long as you stay inside the three functions and keep the diffs small.

Where MonPG stands on MySQL

I build MonPG, so the honest line: MonPG monitors PostgreSQL today, and MySQL support is in active development, not shipped. The numbers in this piece — binlog growth rate per table, undo volume per update, the step change when PARTIAL_JSON kicks in — are exactly what the MySQL work is designed to graph so a config change shows up as evidence rather than a disk alert. The MySQL monitoring (coming soon) page is where that lands as it ships. Meanwhile the same approach runs on the PostgreSQL side today, and more of these MySQL notes live on the blog.