
In this third post of our series, we decode the FORMAT_DESCRIPTION_EVENT — the critical first event in every binary log that tells us how to interpret everything that follows. Introduction The FORMAT_DESCRIPTION_EVENT (FDE) is arguably the most important event in a binary log. It's always the first event after the magic number, and it serves as a self-describing header for the entire file. Without it, we wouldn't know: * Which version of the binary log format we're dealing with * What MySQ

Marcelo Altmann
2026-03-11 · 8 min read
In this third post of our series, we decode the FORMAT_DESCRIPTION_EVENT — the critical first event in every binary log that tells us how to interpret everything that follows.
The FORMAT_DESCRIPTION_EVENT (FDE) is arguably the most important event in a binary log. It's always the first event after the magic number, and it serves as a self-describing header for the entire file. Without it, we wouldn't know:
Think of the FDE as the "Rosetta Stone" of the binary log — it provides the key to decoding everything else.
From Part 2, we know that every event starts with a 19-byte common header. Let's read the FDE's header:
$ xxd -s 4 -l 19 -c 19 -p binlog.000024
6e0f35680f010000007a0000007e0000000000
Decoding:
| Field | Bytes | Value | Meaning |
|---|---|---|---|
| Timestamp | 6e0f3568 | 1748307822 | 2025-05-27 01:03:42 |
| Event Type | 0f | 15 | FORMAT_DESCRIPTION_EVENT |
| Server ID | 01000000 | 1 | Server ID |
| Event Size | 7a000000 | 122 bytes | Total event size |
| Next Position | 7e000000 | 126 | Next event starts at byte 126 |
| Flags | 0000 | 0x0000 | No special flags |
The event type 0x0f (15) confirms this is a FORMAT_DESCRIPTION_EVENT. Now let's decode its payload.
The FDE payload (103 bytes in our case: 122 total - 19 header = 103) contains:
| Field | Size | Description |
|---|---|---|
| Binlog Version | 2 bytes | Version of the binlog format (4 for MySQL 5.0+) |
| MySQL Server Version | 50 bytes | Null-padded server version string |
| Created Timestamp | 4 bytes | Unix timestamp when the binlog was created |
| Event Header Length | 1 byte | Always 19 (0x13) for format version 4 |
| Post-Header Length Array | Variable | One byte per event type, giving the post-header size |
| Checksum Algorithm | 1 byte | 0 = none, 1 = CRC32 |
| Checksum | 4 bytes | CRC32 of the event (if enabled) |
Let's read the payload:
$ xxd -s 23 -l 103 -c 51 -p binlog.000024
0400382e302e343000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013
000d0008000000000400040000006200041a08000000080808020000000a0a0a2a2a001234000a280001d6331bd8
Let's break this down field by field.
04000400 → 0x0004 (little-endian) = 4
Binlog format version 4. This version has been standard since MySQL 5.0 (released in 2005). All modern MySQL installations use version 4.
Previous versions:
| Version | MySQL Version | Notes |
|---|---|---|
| 1 | 3.23 - < 4.0.0 | Statement-based replication, 13-byte header |
| 2 | 4.0.0 - 4.0.1 | Short-lived, only in early alpha versions |
| 3 | 4.0.2 - < 5.0.0 | Added relay logs, changed log position semantics |
| 4 | 5.0.0+ | Added FORMAT_DESCRIPTION_EVENT, extensible protocol |
382e302e34300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Let's decode the ASCII:
38 = '8'
2e = '.'
30 = '0'
2e = '.'
34 = '4'
30 = '0'
00... = null padding
Server version: "8.0.40"
This 50-byte field is always null-padded. The version string can include suffixes like -debug, -log, or distribution-specific markers like -percona.
0000000000000000 → 0
The created timestamp is 0. This is intentional — MySQL uses this field to signal whether the source server has restarted. When a replica receives an FDE with a non-zero created timestamp, it drops all temporary tables and rolls back unfinished transactions. To avoid triggering this during normal log rotation (FLUSH LOGS or size-based rotation), MySQL sets created to 0 for every FDE except the very first one after server startup. The actual event creation time is recorded in the common header's timestamp field.
1313 → 0x13 = 19 bytes
The common event header is 19 bytes. This confirms what we learned in Part 2. For binlog version 4, this is always 19.
This is where things get interesting. The array tells us the "post-header" size for each event type. The post-header is event-specific metadata that comes immediately after the 19-byte common header, before the variable-length payload.
000d0008000000000400040000006200041a08000000080808020000000a0a0a2a2a001234000a2800
Decoded as an array of bytes:
[0, 13, 0, 8, 0, 0, 0, 0, 4, 0, 4, 0, 0, 0, 98, 0, 4, 26, 8, 0, 0, 0, 8, 8, 8, 2, 0, 0, 0, 10, 10, 10, 42, 42, 0, 18, 52, 0, 10, 40, 0]
The index into this array is (event_type - 1). So for event type 15 (FORMAT_DESCRIPTION_EVENT), we look at index 14:
Array index 14 → value 98 (0x62) bytes
The FDE post-header is 98 bytes, which includes the version info, server string, timestamp, header length, and the array itself (minus the checksum fields).
Here are some key post-header sizes:
| Event Type | Index | Post-Header Size | Description |
|---|---|---|---|
| QUERY_EVENT (2) | 1 | 13 bytes | Thread ID, exec time, db len, error code, status len |
| ROTATE_EVENT (4) | 3 | 8 bytes | Position for next file |
| FORMAT_DESCRIPTION_EVENT (15) | 14 | 98 bytes | All the FDE-specific fields |
| XID_EVENT (16) | 15 | 0 bytes | Just the XID in the payload |
| TABLE_MAP_EVENT (19) | 18 | 8 bytes | Table ID and flags |
| WRITE_ROWS_EVENT (30) | 29 | 10 bytes | Table ID, flags, extra data len |
| UPDATE_ROWS_EVENT (31) | 30 | 10 bytes | Same as WRITE_ROWS |
| DELETE_ROWS_EVENT (32) | 31 | 10 bytes | Same as WRITE_ROWS |
| GTID_LOG_EVENT (33) | 32 | 42 bytes | Commit flag, UUID, GNO, logical clock |
MySQL 8.4 introduced GTID_TAGGED_LOG_EVENT (event type 42), which adds one more byte to the post-header length array. This means:
| Version | FDE Size | Post-Header Array | Event Types |
|---|---|---|---|
| MySQL 8.0 | 122 bytes | 41 bytes | 41 |
| MySQL 8.4+ | 123 bytes | 42 bytes | 42 |
If you're building a parser that supports both versions, you'll need to handle this difference. The FDE's self-reported post-header size (at index 14) also increases from 98 to 99 bytes to account for the longer array.
0101 → 1 = CRC32
The checksum algorithm is CRC32. Possible values:
0x00 = Checksum disabled0x01 = CRC32When CRC32 is enabled (the default since MySQL 5.6.2), every event ends with a 4-byte checksum.
d6331bd8d6331bd8 → 0xd81b33d6 (little-endian)
The CRC32 checksum of the event is 0xd81b33d6. This covers the entire event from the header through the checksum algorithm byte.
Let's visualize the complete FORMAT_DESCRIPTION_EVENT:
Position 4: FORMAT_DESCRIPTION_EVENT
┌─────────────────────────────────────────────────────────────────────────┐
│ COMMON HEADER (19 bytes) │
├─────────────────────────────────────────────────────────────────────────┤
│ 6e0f3568 │ 0f │ 01000000 │ 7a000000 │ 7e000000 │ 0000 │
│ Timestamp │ Type │ ServerID │ Size │ NextPos │ Flags │
│ 1748307822 │ 15 │ 1 │ 122 │ 126 │ 0x0000 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ PAYLOAD (103 bytes) │
├─────────────────────────────────────────────────────────────────────────┤
│ 0400 │ Binlog Version: 4 │
├──────────────┼──────────────────────────────────────────────────────────┤
│ 382e302e3430 │ Server Version: "8.0.40" (50 bytes, null-padded) │
│ 00000000... │ │
├──────────────┼──────────────────────────────────────────────────────────┤
│ 00000000 │ Created Timestamp: 0 │
├──────────────┼──────────────────────────────────────────────────────────┤
│ 13 │ Event Header Length: 19 bytes │
├──────────────┼──────────────────────────────────────────────────────────┤
│ 000d000800...│ Post-Header Length Array (41 bytes) │
├──────────────┼──────────────────────────────────────────────────────────┤
│ 01 │ Checksum Algorithm: 1 (CRC32) │
├──────────────┼──────────────────────────────────────────────────────────┤
│ d6331bd8 │ Checksum: 0xd81b33d6 │
└──────────────┴──────────────────────────────────────────────────────────┘
The FORMAT_DESCRIPTION_EVENT is essential for several reasons:
Here's a Python script to decode the FORMAT_DESCRIPTION_EVENT:
import struct
with open('binlog.000024', 'rb') as f:
# Skip magic number
f.seek(4)
# Read common header
header = f.read(19)
timestamp, event_type, server_id, event_size, next_pos, flags = \
struct.unpack('<IBIIIH', header)
print(f"Event Type: {event_type} (FORMAT_DESCRIPTION_EVENT)")
print(f"Event Size: {event_size} bytes")
# Read payload (event_size - header - checksum)
payload_size = event_size - 19
payload = f.read(payload_size)
# Decode FDE fields
binlog_version = struct.unpack('<H', payload[0:2])[0]
server_version = payload[2:52].rstrip(b'\x00').decode('ascii')
created_ts = struct.unpack('<I', payload[52:56])[0]
header_len = payload[56]
print(f"\nBinlog Version: {binlog_version}")
print(f"Server Version: {server_version}")
print(f"Created Timestamp: {created_ts}")
print(f"Header Length: {header_len}")
# Post-header array (event_size - 19 - 2 - 50 - 4 - 1 - 1 - 4 = 41 bytes)
post_header_array = payload[57:98]
print(f"\nPost-Header Array ({len(post_header_array)} entries):")
# Show some interesting entries
event_names = {
2: "QUERY_EVENT",
15: "FORMAT_DESCRIPTION_EVENT",
19: "TABLE_MAP_EVENT",
30: "WRITE_ROWS_EVENT",
33: "GTID_LOG_EVENT"
}
for etype, name in event_names.items():
if etype - 1 < len(post_header_array):
print(f" {name} ({etype}): {post_header_array[etype-1]} bytes post-header")
# Checksum info
checksum_algo = payload[98]
checksum = struct.unpack('<I', payload[99:103])[0]
print(f"\nChecksum Algorithm: {'CRC32' if checksum_algo == 1 else 'None'}")
print(f"Checksum: 0x{checksum:08x}")
Output:
Event Type: 15 (FORMAT_DESCRIPTION_EVENT)
Event Size: 122 bytes
Binlog Version: 4
Server Version: 8.0.40
Created Timestamp: 0
Header Length: 19
Post-Header Array (41 entries):
QUERY_EVENT (2): 13 bytes post-header
FORMAT_DESCRIPTION_EVENT (15): 98 bytes post-header
TABLE_MAP_EVENT (19): 8 bytes post-header
WRITE_ROWS_EVENT (30): 10 bytes post-header
GTID_LOG_EVENT (33): 42 bytes post-header
Checksum Algorithm: CRC32
Checksum: 0xd81b33d6
Now that we understand the FORMAT_DESCRIPTION_EVENT, we're ready to decode actual transaction events. In the next post, we'll examine the PREVIOUS_GTIDS_LOG_EVENT — the event that tracks which GTIDs have already been recorded in prior binary log files.
Next up: Part 4: PREVIOUS_GTIDS_LOG_EVENT — Tracking GTID History
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.
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.