
Replication Internals: Decoding the MySQL Binary Log Part 10: ROTATE_EVENT — Closing the File and Pointing at the Next One
In this tenth post of our series, we decode the ROTATE_EVENT — the event that closes a binary log file and tells every reader, replica or local, where to look next. Introduction The ROTATE_EVENT (event type 4, 0x04) is the bookmark MySQL leaves at the end of a binary log file. It carries two pieces of information: * The name of the binary log file to read next. * The starting position within that next file (always 4, the byte right after the 4-byte magic number from Part 2). Together thos

Marcelo Altmann
2026-05-06 · 10 min read
In this tenth post of our series, we decode the ROTATE_EVENT — the event that closes a binary log file and tells every reader, replica or local, where to look next.
Introduction
The ROTATE_EVENT (event type 4, 0x04) is the bookmark MySQL leaves at the end of a binary log file. It carries two pieces of information:
- The name of the binary log file to read next.
- The starting position within that next file (always
4, the byte right after the 4-byte magic number from Part 2).
Together those two fields are enough for any reader — a replica's I/O thread, a mysqlbinlog invocation, a CDC tool — to seamlessly continue from one file into the next. ROTATE_EVENT also has a second life on the wire: when a replica connects, the source sends an artificial ROTATE_EVENT first to tell the replica which file the upcoming events belong to. We'll see how to tell the two apart.
For our binlog.000024, the ROTATE_EVENT is the last event in the file — every byte before it has now been decoded across Parts 2–9.
Event Location
Position 1397: XID_EVENT (31 bytes) ← Last DML transaction commits
Position 1428: ROTATE_EVENT (44 bytes) ← End of file → binlog.000025
Position 1472: (end of file)
Reading the Raw Bytes
$ xxd -s 1428 -l 44 binlog.000024
00000594: 3910 3568 0401 0000 002c 0000 00c0 0500 9.5h.....,......
000005a4: 0000 0004 0000 0000 0000 0062 696e 6c6f ...........binlo
000005b4: 672e 3030 3030 3235 de7e 7110 g.000025.~q.
The event is 44 bytes: 19-byte common header + 8-byte post-header + 13-byte body (the filename) + 4-byte checksum.
Common Header (19 bytes)
39103568 04 01000000 2c000000 c0050000 0000
│ │ │ │ │ │
│ │ │ │ │ └─→ Flags: 0x0000
│ │ │ │ └───────────→ Next Position: 1472
│ │ │ └────────────────────→ Event Size: 44 bytes
│ │ └─────────────────────────────→ Server ID: 1
│ └────────────────────────────────→ Event Type: 4 (ROTATE_EVENT)
└─────────────────────────────────────────→ Timestamp: 1748308025
| Field | Bytes | Little-Endian | Value |
|---|---|---|---|
| Timestamp | 39103568 | 0x68351039 | 1748308025 (2025-05-27 01:07:05) |
| Event Type | 04 | 0x04 | 4 (ROTATE_EVENT) |
| Server ID | 01000000 | 0x00000001 | 1 |
| Event Size | 2c000000 | 0x0000002c | 44 bytes |
| Next Position | c0050000 | 0x000005c0 | 1472 |
| Flags | 0000 | 0x0000 | No flags |
Cross-check: 1428 + 44 = 1472, and the file is exactly 1472 bytes long. ✓
The flags field is 0x0000. In particular, LOG_EVENT_ARTIFICIAL_F (bit 0x0020, defined in sql/log_event.h:277) is not set — this is a real on-disk ROTATE_EVENT, written by the server when it closed binlog.000024. We'll come back to artificial ROTATE_EVENTs in a moment.
ROTATE_EVENT Structure
The layout is documented in the `Rotate_event` class:
| Field | Size | Description |
|---|---|---|
| Post-Header (8 bytes) | ROTATE_HEADER_LEN | |
| pos | 8 bytes | Byte position at which the first event begins in the next file (little-endian uint64) |
| Body | ||
| new_log_ident | Variable | Name of the next binary log file — no null terminator, length runs to the end of the event |
The body is intentionally not null-terminated. The reader computes its length from the bytes still available after the post-header, minus the trailing 4-byte checksum. From Rotate_event::Rotate_event() in libs/mysql/binlog/event/control_events.cpp:
if (post_header_len) {
READER_ASSERT_POSITION(header_size + R_POS_OFFSET);
READER_TRY_SET(pos, read<uint64_t>);
...
} else
pos = 4;
ident_len = READER_CALL(available_to_read);
if (ident_len == 0) READER_THROW("Event is smaller than expected");
if (ident_len > FN_REFLEN - 1) ident_len = FN_REFLEN - 1;
READER_TRY_SET(new_log_ident, strndup<const char *>, ident_len);
Two things worth noting:
- The fallback
pos = 4exists for legacy binary logs from MySQL versions whereROTATE_EVENThad no post-header. In binlog format version 4 (every modern MySQL), the post-header is always 8 bytes andposis read from it. The default value4is also what we expect to see: it's LOG_EVENT_OFFSET, the byte right after the 4-byte magic number where the first event of any binary log lives. - The filename is capped at
FN_REFLEN - 1(511) bytes, but in practice it's tens of bytes — basenames likebinlog.000025.
The write side (Rotate_log_event::write() in sql/log_event.cc) mirrors the layout exactly:
bool Rotate_log_event::write(Basic_ostream *ostream) {
char buf[Binary_log_event::ROTATE_HEADER_LEN];
int8store(buf + R_POS_OFFSET, pos);
return (
write_header(ostream, Binary_log_event::ROTATE_HEADER_LEN + ident_len) ||
wrapper_my_b_safe_write(ostream, (uchar *)buf,
Binary_log_event::ROTATE_HEADER_LEN) ||
wrapper_my_b_safe_write(ostream,
pointer_cast<const uchar *>(new_log_ident),
(uint)ident_len) ||
write_footer(ostream));
}
int8store is MySQL's 8-byte little-endian integer store (the same primitive we've been seeing since Part 1), then the filename is appended as raw bytes.
Field-by-Field Decoding
Post-Header — pos: 0400000000000000
04 00 00 00 00 00 00 00 → little-endian uint64 → 0x0000000000000004 = 4
The first event in the next binary log starts at byte 4, immediately after the 4-byte magic number. As we saw above, this is LOG_EVENT_OFFSET and it's the value used for every "normal" rotation. (This field exists at all because the same ROTATE_EVENT structure is used when a replica's I/O thread has been told to start reading from a non-default offset — but that's a special case the server-side rotation never produces.)
Body — new_log_ident: 13 bytes
62 69 6e 6c 6f 67 2e 30 30 30 30 32 35
b i n l o g . 0 0 0 0 2 5
The next binary log is binlog.000025. The string runs from the start of the body to the byte before the 4-byte checksum — there is no null terminator and no length prefix.
Checksum: de7e7110
de 7e 71 10 → CRC32 of the entire event
Visual Breakdown
Position 1428: ROTATE_EVENT (44 bytes)
┌─────────────────────────────────────────────────────────────────────────┐
│ COMMON HEADER (19 bytes) │
├─────────────────────────────────────────────────────────────────────────┤
│ 39103568 │ 04 │ 01000000 │ 2c000000 │ c0050000 │ 0000 │
│ Timestamp │ Type │ ServerID │ Size │ NextPos │ Flags │
│ 1748308025 │ 4 │ 1 │ 44 │ 1472 │ 0x0000 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ POST-HEADER (8 bytes) │
├──────────────────────────────┬──────────────────────────────────────────┤
│ 0400000000000000 │ pos: 4 (start of first event in next file)│
└──────────────────────────────┴──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BODY (13 bytes) │
├──────────────────────────────┬──────────────────────────────────────────┤
│ 62696e6c6f672e303030303235 │ new_log_ident: "binlog.000025" │
│ │ (no null terminator; length runs to CRC) │
└──────────────────────────────┴──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ CHECKSUM (4 bytes) │
├──────────────────────────────┬──────────────────────────────────────────┤
│ de7e7110 │ CRC32 │
└──────────────────────────────┴──────────────────────────────────────────┘
mysqlbinlog output: Rotate to binlog.000025 pos: 4
When Is a ROTATE_EVENT Written?
The on-disk ROTATE_EVENT we just decoded is written by MYSQL_BIN_LOG::new_file_impl() — the function the server calls whenever it has decided to close the current binary log file and start a new one. There are several triggers that flow into that single code path:
| Trigger | Description |
|---|---|
| max_binlog_size reached | The current file grew past the configured limit (default 1 GB); the server rotates as soon as the next transaction tries to append. |
| FLUSH BINARY LOGS / FLUSH LOGS | A user (or a backup tool) explicitly requested a rotation. |
| Server startup | Each restart begins a new binary log file, so the previous file gets a closing ROTATE_EVENT pointing at the new one. |
| RESET BINARY LOGS AND GTIDS | Wipes existing logs and starts fresh; the residual rotate marker preserves the file linkage during the reset. |
All four paths produce the same on-disk shape: post-header pos = 4, body = the next file's basename, no special flags. The construction at the call site looks like this:
Rotate_log_event r(new_name + dirname_length(new_name), 0, LOG_EVENT_OFFSET,
is_relay_log ? Rotate_log_event::RELAY_LOG : 0);
LOG_EVENT_OFFSET is 4, and dirname_length(new_name) strips the directory so only the basename ends up in the body. The RELAY_LOG flag is a runtime-only bit on the in-memory class (used to tag relay-log rotations on the replica side); it is not written into the on-disk flags field, which is why our event shows 0x0000 even though it lives in a binlog (rather than relay log).
Two Kinds of ROTATE_EVENT
ROTATE_EVENT also shows up on the network protocol between source and replica, in two different forms. Telling them apart is mostly a matter of looking at the common header.
Real (on-disk) ROTATE_EVENT — the one we just decoded:
- Written by the source as the last event of a binary log file when it closes that file.
- Has a real timestamp (the rotation time).
- Flags =
0x0000(noLOG_EVENT_ARTIFICIAL_F). - The
next_positionfield in the common header points past the end of the current file. - Persisted to disk; it stays in the file forever.
Artificial ROTATE_EVENT — the very first event a replica's I/O thread receives when it (re)connects to a source. It exists so the replica knows which file the upcoming events belong to, before it has seen any FORMAT_DESCRIPTION_EVENT for that file:
- Generated on the fly in memory by the source's dump thread; never written to disk.
- Timestamp =
0(epoch) — this is a tell-tale sign you're looking at an artificial event. - Flag
LOG_EVENT_ARTIFICIAL_F(0x0020) is set in the common header. - The body still carries a filename and a position, but they describe where the dump thread will start streaming from, not a file boundary.
Both shapes parse with exactly the same code; only the surrounding context (and those two flag/timestamp tells) distinguishes them. The class doc on Rotate_event calls this out:
ROTATE_EVENT is generated locally and written to the binary log on the master. It is written to the relay log on the slave when FLUSH LOGS occurs, and when receiving a ROTATE_EVENT from the master. In the latter case, there will be two rotate events in total originating on different servers.
Try It Yourself
import struct
from datetime import datetime, timezone
ROTATE_EVENT = 4
LOG_EVENT_ARTIFICIAL_F = 0x0020
with open('binlog.000024', 'rb') as f:
f.seek(1428)
header = f.read(19)
timestamp, event_type, server_id, event_size, next_pos, flags = \
struct.unpack('<IBIIIH', header)
assert event_type == ROTATE_EVENT, f"Not a ROTATE_EVENT (got {event_type})"
# Post-header: 8-byte little-endian position
pos = struct.unpack('<Q', f.read(8))[0]
# Body: filename runs up to the 4-byte CRC at the end of the event
body_len = event_size - 19 - 8 - 4
new_log_ident = f.read(body_len).decode('ascii')
crc = f.read(4)
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc) if timestamp \
else "(epoch — likely artificial)"
print(f"ROTATE_EVENT at offset 1428 ({event_size} bytes)")
print(f" Timestamp: {timestamp} ({ts})")
print(f" Server ID: {server_id}")
print(f" Next position: {next_pos}")
print(f" Flags: 0x{flags:04x}"
f"{' [ARTIFICIAL]' if flags & LOG_EVENT_ARTIFICIAL_F else ''}")
print(f" pos: {pos}")
print(f" new_log_ident: {new_log_ident!r}")
print(f" CRC32: {crc.hex()}")
print(f" → Rotate to {new_log_ident} pos: {pos}")
Output:
ROTATE_EVENT at offset 1428 (44 bytes)
Timestamp: 1748308025 (2025-05-27 01:07:05+00:00)
Server ID: 1
Next position: 1472
Flags: 0x0000
pos: 4
new_log_ident: 'binlog.000025'
CRC32: de7e7110
→ Rotate to binlog.000025 pos: 4
Cross-check against mysqlbinlog:
$ mysqlbinlog --no-defaults binlog.000024 | tail -5
# at 1428
#250527 1:07:05 server id 1 end_log_pos 1472 CRC32 0x1071 7ede Rotate to binlog.000025 pos: 4
Note: The binary log files used in this series (
binlog.000024,binlog_gtid_tag.000001, and others) are available at github.com/altmannmarcelo/presentations/tree/main/binlog.
References
- Rotate_event — class definition with the binary format documentation
- Rotate_event::Rotate_event() — deserialization constructor (post-header
pos, then filename runs to the end of the event) - Rotate_log_event::write() — serialization (
int8storeforpos, raw bytes for filename) - R_POS_OFFSET / R_IDENT_OFFSET — post-header field offsets
- ROTATE_HEADER_LEN = 8 — post-header length constant
- Rotate_event::DUP_NAME / RELAY_LOG — runtime-only flags on the in-memory class (not on-disk)
- MYSQL_BIN_LOG::new_file_impl() — constructs and writes the on-disk ROTATE_EVENT during rotation
- LOG_EVENT_ARTIFICIAL_F — the common-header flag set on artificial ROTATE_EVENTs sent to replicas
What's Next?
That's the last event in binlog.000024. Across nine posts (Parts 2 through 10), we've now manually decoded every byte of a real MySQL binary log — from the magic number to the closing ROTATE_EVENT — and seen how each event maps onto MySQL's source code in mysql-9.6.0.
But there's one event we deliberately deferred all the way back in Part 5: the GTID_TAGGED_LOG_EVENT introduced in MySQL 8.4. Tagged GTIDs use a different on-disk encoding from the classic untagged GTID we decoded, and they're worth a post of their own. That's the subject of Part 11.
Next up: Part 11: GTID_TAGGED_LOG_EVENT — Tagged GTIDs in MySQL 8.4+
This series is based on a presentation given at the MySQL Online Summit. The goal is to help MySQL users understand what goes under the hood of replication by manually decoding binary log files.
Still scaling the hard way?
Modern applications demand instant performance, even under unpredictable load. Readyset helps you eliminate slow queries, stabilize latency, and scale confidently.
Revolutionize your database performance with Readyset
Serve requests at sub-millisecond latencies with the modern database scaling and query caching system for MySQL and PostgreSQL.
Join our newsletter
Stay updated with the latest news, insights, and developments from Readyset — straight to your inbox.


