Pilotbase on a MySQL 8.4 copy restored from the binlog with the bad DELETE skipped: 4,006,660 orders, 3,925,297 of them older than October, 5,340,242 items

A purge meant for orders older than three years went out with 2026-10-01 in place of 2023-10-01. MySQL took 56 seconds to remove 3,925,297 rows, and the shop carried on taking orders for three more minutes before anybody looked. Last night's backup would bring the rows back and lose the rest of the day. The binlog can do better: it holds every committed change in commit order, and it holds the deleted rows themselves.

This is the MySQL counterpart of our Postgres point-in-time recovery run, with one difference that changes the plan. Postgres WAL recovery has to stop at a point, so everything after it is gone. MySQL can replay past the mistake and skip only that one transaction. We tried four ways of replaying and two ways of copying rows back on MySQL 8.4.11. One of those fixes looks perfect until you look at the child table.

Everything below is from that run, on an Apple silicon Mac mini (10 cores, 16 GB, internal SSD) that had other jobs running at the same time, so read the times as sizes rather than as benchmarks. The database was 4,000,000 orders and 5,333,582 order items, 1.9 GB on disk. Live traffic was about 25 transactions a second: new orders, payments, and a job writing the order count to a stats table every 15 seconds.

What has to be on before the accident

Most of it already is. These are the MySQL 8.4 defaults that matter here:

text
setting                         8.4 default     why it matters
log_bin                         ON              no binlog, no recovery
binlog_format                   ROW             deprecated as a setting, but ROW is what you want
binlog_row_image                FULL            a DELETE logs every column, so the rows can be rebuilt
sync_binlog                     1               a committed transaction is on disk in the binlog
binlog_expire_logs_seconds      2592000         30 days; must reach back to your oldest usable backup
max_binlog_size                 1 GB            we used 256 MB; a transaction is never split across files
gtid_mode                       OFF             not required, but it makes the skip below much simpler

We added two lines, then one more after this run taught us why:

text
# my.cnf
gtid_mode = ON
enforce_gtid_consistency = ON
binlog_rows_query_log_events = ON    # off by default: stores the SQL text next to the row events

The third is the cheap one to forget. Without it a row event says which rows changed but never which statement did it, and you end up identifying a DELETE by its size, as we do below. With it, mysqlbinlog -vv prints the statement. Here it is on a small test:

text
#261103  0:56:59 server id 630  end_log_pos 786 CRC32 0xd389702d 	Rows_query
# DELETE FROM stats WHERE at='2020-01-01'

The binlog also has to survive the server. A binlog on the same disk fixes a bad DELETE and nothing worse. mysqlbinlog can pull the files to another machine as exact copies. We copied the 1 GB file over a local connection in 2.1 seconds and the checksums matched; on a real network the link sets the speed. We ran it with --to-last-log; --stop-never keeps it following the server instead of exiting:

text
mysqlbinlog --read-from-remote-server --host=db1 --user=repl --password \
    --get-server-public-key --raw --stop-never binlog.000001

What keeping it costs: outside the DELETE, 5,039 transactions took 3.1 MB of binlog, about 616 bytes each. At 25 transactions a second that's 616 × 25 × 86,400 = 1.33 GB a day, or about 40 GB for the default 30 days. Your rows are probably wider than ours.

Two base backups, taken under load

text
# logical: a consistent snapshot, with its GTID set written at the top of the file
mysqldump --single-transaction --source-data=2 --set-gtid-purged=ON --routines --events \
    --databases shop | gzip -1 > dump.sql.gz

-- physical: a running copy of the data directory
INSTALL PLUGIN clone SONAME 'mysql_clone.so';
CLONE LOCAL DATA DIRECTORY = '/backups/clone';
text
                     backup     size       restore             backup's position
mysqldump | gzip     8.2 s      505 MB     57.6 s (mysql)      GTID 1-536
CLONE LOCAL          8.9 s      1.9 GB     10.7 s (cp -R)      GTID 1-1074, binlog.000001:665422

mysqlpump is gone in 8.4, so these are the two you get without installing anything. Percona XtraBackup is the other common choice; we didn't test it. Note what each backup tells you about where it stands. The dump has SET @@GLOBAL.GTID_PURGED near the top. A cloned directory, once started, reports it in performance_schema.clone_status (BINLOG_FILE, BINLOG_POSITION, GTID_EXECUTED). With GTIDs you don't have to do anything with that number: replayed transactions the server already has are skipped without complaint, so you can feed it the whole binlog file.

CLONE LOCAL writes to the same machine, so copy the directory somewhere else afterwards.

The accident

text
DELETE FROM orders WHERE created_at < '2026-10-01';   -- meant '2023-10-01'

statement started        00:41:57.75 UTC
commit timestamp         00:42:41.167
statement returned       00:42:54.15   (56.3 s)
rows deleted             3,925,297 orders, plus 5,234,068 order_items by ON DELETE CASCADE
binlog                   1,026,365,895 bytes in 127,154 Delete_rows events, about 261 bytes a row

Look at the gap between the commit timestamp and the statement returning. Nothing on the server committed between 00:42:41.167 and 00:42:54.044: the 1 GB transaction was going into the binlog, and the next transaction logged exec_time=13. So one big DELETE stopped every commit for 13 seconds, on top of everything else. That's the case for deleting in batches, even when the WHERE clause is right.

Three minutes later somebody noticed. By then another 5,039 transactions had committed, 3,800 of them new orders.

Finding the DELETE: its GTID and its position

Without row decoding, mysqlbinlog only prints event headers, which makes it quick: 1.3 seconds for this 1 GB file. Set TZ, because it prints times in the machine's zone. We didn't have the statement text in this run, so this small awk script lists every transaction by size:

awk
# reads `mysqlbinlog --base64-output=DECODE-ROWS` headers; prints one line per transaction: bytes, GTID, start position, delete events, commit time
function flush() { if (g != "") printf "%12d  %s  at %d  deletes=%d  %s\n", at - start, g, start, d, c }
/^# at /                  { at = $3 }
/\tGTID\t/                { flush(); start = at; d = 0; g = "" }
/^# original_commit_time/ { c = $4 " " $5 }
/GTID_NEXT= '/            { split($0, a, "'"); g = a[2] }
/Delete_rows:/            { d++ }
END                       { flush() }
text
$ export TZ=UTC
$ mysqlbinlog --base64-output=DECODE-ROWS binlog.000001 | awk -f find_big.awk | sort -rn | head -3
  1026365895  d1564ed2-d71e-11f1-8640-0700ea963239:3867  at 2391903  deletes=127154  00:42:41.167181 UTC)
        1145  d1564ed2-d71e-11f1-8640-0700ea963239:1632  at 1008933  deletes=0  00:41:18.667395 UTC)
        1141  d1564ed2-d71e-11f1-8640-0700ea963239:3188  at 1961684  deletes=0  00:42:10.572751 UTC)

A 1 GB transaction next to 1 KB ones isn't subtle. A small accident will be, and that's where the statement text earns its keep. To check the find, read the start of that transaction. The two values you need are marked:

text
# at 2391872
#261103  0:42:41 server id 630  end_log_pos 2391903 	Xid = 10756
COMMIT/*!*/;
# at 2391903                                                     <- stop here: the last good transaction ended at this byte
#261103  0:41:57 server id 630  end_log_pos 2391988 	GTID	last_committed=3866	sequence_number=3867	rbr_only=yes
# original_commit_timestamp=1793666561167181 (2026-11-03 00:42:41.167181 UTC)
SET @@SESSION.GTID_NEXT= 'd1564ed2-d71e-11f1-8640-0700ea963239:3867'/*!*/;     <- skip this
# at 2391988
#261103  0:41:57 server id 630  end_log_pos 2392073 	Query	thread_id=22	exec_time=0	error_code=0
BEGIN
# at 2392073
#261103  0:41:57 server id 630  end_log_pos 2392156 	Table_map: `shop`.`orders` mapped to number 147

The transaction just before it in the file is stamped 00:42:41, and this one is stamped 00:41:57. Event headers carry the time the statement started, but transactions go into the binlog in commit order. So timestamps run backwards at exactly the place you care about.

Why --stop-datetime is the wrong tool

The manual says to use the datetime options only to find positions, and warns they can miss events. This is what that looks like. --stop-datetime stops at the first event stamped at or after the time you give it, and the good transaction just before the DELETE is GTID 3866. We asked for five different times:

text
--stop-datetime   last GTID replayed   result
00:41:58          2770                 loses 1,096 good transactions that committed during the DELETE
00:42:30          3680                 loses 186 good transactions
00:42:41          3861                 loses 5 good transactions
00:42:42          3867                 replays the DELETE
00:42:54          3867                 replays the DELETE

Look at the last two rows. Pick a time after the DELETE committed and it's replayed, because its events are stamped 00:41:57 and never trip the check. One second earlier you lose five good transactions. In this run no time lands on 3866. Use the position or the GTID.

Stop before it: --stop-position

Restore the base backup to a spare server first, never over production. Then the replay is one pipe. Name all the files in one mysqlbinlog command so they share a connection; the manual calls one pipe per file unsafe, because a temporary table made in one file is gone by the next. --stop-position applies to the last file named:

text
# on the spare server, started from a copy of the clone
mysqlbinlog --stop-position=2391903 binlog.000001 | mysql -h spare -u root -p

took                  2.24 s
gtid_executed         ...:1-3866
orders                4,002,860   (3,925,297 older than October: all back)
order_items           5,336,442

It had 2,792 transactions to apply, from the clone's GTID 1074 up to 3866, which works out at about 1,250 a second through one connection. That's the rate to plan with: a full day of binlog at 25 transactions a second is 2,160,000 transactions, or roughly 29 minutes of replay at that speed. That's an estimate, and busy tables with big transactions will take longer. From the dump instead of the clone, the same replay took 1.70 s after the 57.6 s restore, and gave the same counts.

If you get the position wrong by landing inside the transaction, it's forgiving. We stopped at 2400049, after the first Delete_rows event. mysqlbinlog warned about a partial statement and wrote a ROLLBACK, and the server ended up at the same 1-3866 with all its orders. Get it wrong the other way, past the end of the transaction, and you've replayed the DELETE, so check the count before you trust the copy.

What this costs: the 3,800 orders and all the payments after the DELETE aren't in this copy. In Postgres that's where the road ends and you copy the lost rows out by hand. In MySQL you have a better option.

Skip it and keep going: --exclude-gtids

text
mysqlbinlog -D --exclude-gtids='d1564ed2-d71e-11f1-8640-0700ea963239:3867' \
    binlog.000001 binlog.000002 | mysql -h spare -u root -p

took                  7.61 s   (it still reads past the 1 GB it is skipping)
gtid_executed         ...:1-3866:3868-8906
orders                4,006,660   (every order: old ones back, new ones kept)
order_items           5,340,242
Pilotbase on the MySQL copy restored with --exclude-gtids: 4,006,660 orders, 3,925,297 older than October, last order id 4006660, 5,340,242 items, gtid_executed with a gap at 3867

All rows, nothing lost. -D adds SET sql_log_bin=0 so the replay doesn't fill the spare's own binlog (ours stayed at 1.8 MB), and the GTIDs are still recorded. The hole at 3867 in gtid_executed matters only if this copy will ever be a replica or a source: an auto-position connection would ask for 3867 and get the DELETE. Close it with an empty transaction first, the same way the next route does.

Skipping a transaction doesn't undo what other transactions did because of it. Our stats job counted orders while the damage was live, and its rows replayed faithfully:

Pilotbase showing the stats table on the restored copy: order_count around 4,000,000 until 00:41:53, then 77,575 at 00:42:09 and about 80,000 after that

Twelve of the 19 stats rows say about 80,000 orders on a database that now holds four million. The row stamped 00:42:09 tells you something too: that INSERT ... SELECT started during the DELETE, waited on its row locks, and committed after it, so it counted the damage. Anything computed from the damaged tables in that window (rollups, invoices, emails sent, a customer's order count) needs its own look. A later transaction that re-inserted one of the deleted primary keys would fail with a duplicate key error on replay, because with the DELETE skipped the row is still there. Our workload didn't do that, but a person repairing rows by hand during the three minutes would.

The same two routes through replication

The binlog doesn't have to go through a pipe. Point the spare at production as a replica, and let the applier stop or skip for you. It uses parallel workers if you have them, and the server doesn't send what you tell it to skip:

text
-- on production: a replication user (REPLICATION SLAVE is still the privilege's name)
CREATE USER repl@'%' IDENTIFIED BY '...'; GRANT REPLICATION SLAVE ON *.* TO repl@'%';

-- on the spare, started with --skip-replica-start
CHANGE REPLICATION SOURCE TO SOURCE_HOST='db1', SOURCE_USER='repl', SOURCE_PASSWORD='...',
    SOURCE_AUTO_POSITION=1, GET_SOURCE_PUBLIC_KEY=1;

-- stop just before it
START REPLICA UNTIL SQL_BEFORE_GTIDS='d1564ed2-d71e-11f1-8640-0700ea963239:3867';

-- or skip it: mark it done with an empty transaction, then replicate everything
SET GTID_NEXT='d1564ed2-d71e-11f1-8640-0700ea963239:3867'; BEGIN; COMMIT; SET GTID_NEXT='AUTOMATIC';
START REPLICA;
text
route, from the 1.9 GB clone                     replay    orders      items       gtid_executed
mysqlbinlog --stop-position                      2.24 s    4,002,860   5,336,442   1-3866
START REPLICA UNTIL SQL_BEFORE_GTIDS (4 workers) 2.25 s    4,002,860   5,336,442   1-3866
mysqlbinlog -D --exclude-gtids                   7.61 s    4,006,660   5,340,242   1-3866:3868-8906
empty transaction, then START REPLICA (4 workers) 2.79 s   4,006,660   5,340,242   1-8906

The skip through replication took 2.79 s against 7.61 s because the source never sent the DELETE. The relay log held 3 MB where the file on disk held 1 GB. That matters more over a slow link. Our workload was too small to show what parallel workers can do, so don't read 2.24 against 2.25 as a tie on a real replay. This route also leaves gtid_executed with no hole. It does need the source's binlogs online; mysqlbinlog also works from copies when the server is dead. Run RESET REPLICA ALL on the spare before you use it for anything else.

The lighter fix: the rows are already in the binlog

Because binlog_row_image is FULL, the DELETE logged every column of every row. You can rebuild them with no base backup and no spare server, and production never stops. Ask for only that transaction, with -vv for the column types:

text
$ mysqlbinlog --base64-output=DECODE-ROWS -vv --include-gtids='d1564ed2-...:3867' binlog.000001
### DELETE FROM `shop`.`orders`
### WHERE
###   @1=1 /* LONGINT meta=0 nullable=0 is_null=0 */
###   @2=116563 /* INT meta=0 nullable=0 is_null=0 */
###   @3=4 /* ENUM(1 byte) meta=63233 nullable=0 is_null=0 */
###   @4=49.52 /* DECIMAL(10,2) meta=2562 nullable=0 is_null=0 */
###   @5=0 /* TINYINT meta=0 nullable=0 is_null=0 */
###   @6='USD' /* STRING(12) meta=65036 nullable=0 is_null=0 */
###   @7='2022-01-01 00:00:38.167200' /* DATETIME(6) meta=6 nullable=0 is_null=0 */
###   @8=1640995401.167200 /* TIMESTAMP(6) meta=6 nullable=1 is_null=0 */

This looks one regular expression away from SQL, and it nearly is. But the output isn't SQL, and we put awkward values in the table to see what would break:

text
column type        what mysqlbinlog prints            what you have to do
ENUM               @3=4                               the index, not 'refunded'; inserting the number works
TINYINT            @5=-28 (228)                       signed, then unsigned; pick by the column (INT, BIGINT too)
TIMESTAMP          @8=1640995401.167200               seconds since the epoch; FROM_UNIXTIME in a +00:00 session
BINARY(16)         raw bytes with \x escapes          trailing 00 bytes are dropped; BINARY pads them back
VARCHAR            'it\'s "fragile"', 'C:\\temp'       MySQL-looking escapes, raw UTF-8 bytes; decode, then write as hex
JSON               '{"ip": "10.108.131.1", ...}'      text; needs CONVERT(x'..' USING utf8mb4) to load from hex
any column         @n, never a name                   positional: the table must not have been altered since

This script handles those and writes multi-row INSERTs. It reads only DELETE events and it has only met the column types above, so test it on your own table first. One known gap: for a high value in an UNSIGNED column, mysqlbinlog prints @2=-56 (200) and the script writes the signed -56, which strict mode rejects with an error. Take the number in brackets for those columns.

python
#!/usr/bin/env python3
"""Turn the DELETE row events from `mysqlbinlog --base64-output=DECODE-ROWS -vv` (stdin) into INSERTs (stdout).
Needs binlog_row_image=FULL. Columns are positional, so the table must still have the shape it had at the DELETE."""
import re, sys
ROW = re.compile(rb"^###   @\d+=(.*) /\* (\S+?)(?:\(.*?\))? .*?meta=\d+ nullable=\d is_null=(\d) \*/$")
ESC = {b"\\": 92, b"'": 39, b'"': 34, b"a": 7, b"b": 8, b"f": 12, b"n": 10, b"r": 13, b"t": 9, b"v": 11}

def unquote(v):                       # '...' with \\, \', \n and \xHH back to the raw bytes
    return re.sub(rb"\\(x[0-9a-fA-F]{2}|.)",
                  lambda m: bytes([int(m[1][1:], 16) if len(m[1]) == 3 else ESC[m[1]]]), v[1:-1], flags=re.S)

def literal(v, typ):
    if typ == b"TIMESTAMP":           # printed as seconds since the epoch
        return b"FROM_UNIXTIME(" + v + b")"
    if typ in (b"STRING", b"VARSTRING", b"BLOB"):  # hex keeps quotes, emoji and binary intact
        return b"x'" + unquote(v).hex().encode() + b"'"
    if typ == b"JSON":
        return b"CONVERT(x'" + unquote(v).hex().encode() + b"' USING utf8mb4)"
    if v.startswith(b"'"):            # DATETIME, DATE, TIME
        return v
    return v.split(b" ")[0]           # numbers: -28 (228) is signed first, an UNSIGNED column wants the one in brackets

out = sys.stdout.buffer
out.write(b"SET time_zone='+00:00', foreign_key_checks=0;\n")
table, row, batch, total = None, [], [], 0
def flush():
    global batch
    if batch:
        out.write(b"INSERT INTO " + table + b" VALUES\n" + b",\n".join(batch) + b";\n")
        batch = []
def end_row():
    global row, total
    if row:
        batch.append(b"(" + b",".join(row) + b")"); row = []; total += 1
        if len(batch) == 1000: flush()
for line in sys.stdin.buffer:
    line = line.rstrip(b"\n")
    if line.startswith(b"### DELETE FROM "):
        end_row()
        name = line[16:]
        if name != table: flush(); table = name
    elif line.startswith(b"###   @") and table:
        m = ROW.match(line)
        row.append(b"NULL" if m[3] == b"1" else literal(m[1], m[2]))
end_row(); flush()
print(f"{total} rows", file=sys.stderr)
text
mysqlbinlog --base64-output=DECODE-ROWS -vv --include-gtids='d1564ed2-...:3867' binlog.000001 \
    | ./binlog_rows.py | gzip -1 > undo.sql.gz
gzcat undo.sql.gz | mysql -h db1 -u root -p shop

extract               65.6 s    3,925,297 rows, 487 MB compressed
insert                51.6 s    into the damaged table, on a copy with no other traffic
new binlog            1.03 GB   the repair is itself replicated, like any other write

Then we checked it. SUM(CRC32(CONCAT_WS('|', ...all columns...))) over the deleted id range came to 8431441488110707 on the rebuilt table and on the copy restored by --stop-position. Same number, on 3,925,297 rows that include emoji, backslashes, tabs, newlines and ids ending in zero bytes. Every value came back.

What the binlog doesn't have: the cascade

Then we looked at the child table:

text
after the binlog-only fix
orders                               4,006,660
order_items                            106,174     (should be 5,340,242)
orders with no items                 3,925,297

The DELETE's transaction contains one table. Every Table_map in it says shop.orders, and order_items doesn't appear. InnoDB runs ON DELETE CASCADE inside the storage engine, and the manual is plain about it: the cascading deletes are handled internally, "which means that none of the changes are logged". A replica gets away with this because it runs the same cascade itself. For you it means 5,234,068 deleted rows with no event in this transaction and no copy of what they held. Foreign keys won't flag it either, because an order with no items is perfectly valid. The INSERT script finishes without an error, and the checksum on the table you checked is right.

So check before you choose this route. If the table has children, it won't be enough:

text
SELECT table_name, constraint_name, delete_rule
FROM information_schema.referential_constraints
WHERE constraint_schema = 'shop' AND referenced_table_name = 'orders';

Any row with CASCADE or SET NULL means you need a restored copy for that table, and child tables of those tables too.

Copying back from the restored copy

That's the fix for the general case, and it's what we would do on a real shop. Keep production running, restore a spare with --stop-position or --exclude-gtids, and copy the missing rows across, parents first:

text
D="mysqldump -h spare --single-transaction --no-create-info --skip-add-locks --skip-disable-keys \
   --skip-triggers --hex-blob --set-gtid-purged=OFF --insert-ignore"

$D --where="created_at < '2026-10-01'" shop orders    | mysql -h db1 shop     # 55.8 s
$D --where="order_id <= 3925297"       shop order_items | mysql -h db1 shop   # 25.3 s

Those flags matter. mysqldump's defaults wrap each table in LOCK TABLES ... WRITE, which would stop your live traffic on the table you are repairing. --skip-add-locks removes that, --insert-ignore leaves alone any row that's already there, --hex-blob keeps binary columns intact, and --set-gtid-purged=OFF keeps the dump from trying to rewrite the server's GTID history. Our timings were on a copy of production with no traffic running, so expect longer under load. Result: 4,006,660 orders, 5,340,242 items, no order without its items, and the same checksum.

text
fix                                time     writes stop?   needs                  orders   items   later writes
binlog rows -> INSERT              117 s    no             the binlog             all      NO      kept
spare (--exclude-gtids), copy back ~100 s   no             backup + binlog        all      all     kept
rewind production, --stop-position ~15 s    yes            backup + binlog        all      all     LOST
rewind production, --exclude-gtids ~20 s    yes            backup + binlog        all      all     kept, wrong stats stay

The two rewind times are the 10.7 s copy of the clone plus the replay, with server start on top. The ~100 s is the same plus the 81 s copy-back. None of them include the time spent noticing the problem. In our case that was the longest part, and it was when the 3,800 orders came in.

We'd use them like this. For a small delete on a table with no children, use the binlog fix. It's the only one that needs nothing but the file, and a few thousand rows won't need the minutes above. For anything with children, use the spare and copy back. Only rewind production itself if the damage is so tangled that copying rows back isn't possible, and tell the business first what the rewind costs. MariaDB users have mariadb-binlog --flashback, which reverses DML events directly, under the same FULL row image requirement; we didn't test it, and its docs warn that it holds events in memory.

Two things this doesn't cover

DROP and TRUNCATE. They have no row images: the binlog holds the statement and nothing else, so the binlog-only route is out. Replay from a base backup and stop at or skip the GTID, as above. A delayed replica is a quicker undo for this, if you already run one.

Erased data. The same property that saved us here works against you. A row you deleted on purpose stays readable in every binlog until it expires, and in every off-server copy for as long as you keep them. If you erase users for privacy reasons, your binlog retention belongs in that policy.

A drill that takes ten minutes

text
0. once, on production:  CREATE TABLE drill_log (at datetime(6) PRIMARY KEY, note varchar(20));
1. INSERT INTO drill_log VALUES (NOW(6), 'before');
2. DELETE FROM drill_log WHERE note = 'before';
3. INSERT INTO drill_log VALUES (NOW(6), 'after');
4. find the DELETE's GTID:
   mysqlbinlog --base64-output=DECODE-ROWS -vv <newest file> \
     | grep -B30 'DELETE FROM `shop`.`drill_log`' | grep GTID_NEXT | tail -1
5. restore last night's backup to a spare, replay every binlog since with --exclude-gtids
6. both rows should be there: 'before' came back, 'after' was kept

Time step 5 from where the backup and binlogs really live. That number, plus however long your day's binlog takes at your replay rate, is how long you're down on the day it counts. Also check SHOW BINARY LOGS against your backup schedule: if the oldest binlog is newer than your newest usable backup, none of this works.

Open production and the restored copy side by side, run the same counts and checksums on each, and browse the rows before you copy them back. Pilotbase is free for Windows, macOS and Linux, or Docker on a server.

Get Pilotbase

Exported the restored rows and the live table to CSV? csvDiff shows which rows are missing or changed between the two files, in your browser with nothing uploaded.

Open csvDiff

Related: Point-in-time recovery in Postgres · Deleting millions of rows without hurting production

Sources, checked 3 November 2026: MySQL 8.4 Reference Manual: mysqlbinlog (--stop-position, --exclude-gtids, --disable-log-bin, one connection for several logs), point-in-time recovery using event positions (the warning on datetime options), binary logging options (defaults, transactions not split across files, binlog_rows_query_log_events), START REPLICA (SQL_BEFORE_GTIDS), InnoDB and MySQL replication (cascades not logged). MariaDB Flashback. All measurements are our own, on MySQL 8.4.11, 3 November 2026, with generated data.