Replication Internals: Decoding the MySQL Binary Log Part 3: FORMAT_DESCRIPTION_EVENT — The Self-Describing Event
← Back to blogMySQL

Replication Internals: Decoding the MySQL Binary Log Part 3: FORMAT_DESCRIPTION_EVENT — The Self-Describing Event

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

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.


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 MySQL version created the log
  • How large the common header is
  • How to find the payload portion of each event type

Think of the FDE as the "Rosetta Stone" of the binary log — it provides the key to decoding everything else.


Event Header Recap

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:

FieldBytesValueMeaning
Timestamp6e0f356817483078222025-05-27 01:03:42
Event Type0f15FORMAT_DESCRIPTION_EVENT
Server ID010000001Server ID
Event Size7a000000122 bytesTotal event size
Next Position7e000000126Next event starts at byte 126
Flags00000x0000No special flags

The event type 0x0f (15) confirms this is a FORMAT_DESCRIPTION_EVENT. Now let's decode its payload.


FORMAT_DESCRIPTION_EVENT Payload Structure

The FDE payload (103 bytes in our case: 122 total - 19 header = 103) contains:

FieldSizeDescription
Binlog Version2 bytesVersion of the binlog format (4 for MySQL 5.0+)
MySQL Server Version50 bytesNull-padded server version string
Created Timestamp4 bytesUnix timestamp when the binlog was created
Event Header Length1 byteAlways 19 (0x13) for format version 4
Post-Header Length ArrayVariableOne byte per event type, giving the post-header size
Checksum Algorithm1 byte0 = none, 1 = CRC32
Checksum4 bytesCRC32 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.


Field-by-Field Decoding

Binlog Version: 0400

0400 → 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:

VersionMySQL VersionNotes
13.23 - < 4.0.0Statement-based replication, 13-byte header
24.0.0 - 4.0.1Short-lived, only in early alpha versions
34.0.2 - < 5.0.0Added relay logs, changed log position semantics
45.0.0+Added FORMAT_DESCRIPTION_EVENT, extensible protocol

MySQL Server Version: 50 bytes

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.

Created Timestamp: 00000000

00000000 → 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.

Event Header Length: 13

13 → 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.

Post-Header Length Array

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 TypeIndexPost-Header SizeDescription
QUERY_EVENT (2)113 bytesThread ID, exec time, db len, error code, status len
ROTATE_EVENT (4)38 bytesPosition for next file
FORMAT_DESCRIPTION_EVENT (15)1498 bytesAll the FDE-specific fields
XID_EVENT (16)150 bytesJust the XID in the payload
TABLE_MAP_EVENT (19)188 bytesTable ID and flags
WRITE_ROWS_EVENT (30)2910 bytesTable ID, flags, extra data len
UPDATE_ROWS_EVENT (31)3010 bytesSame as WRITE_ROWS
DELETE_ROWS_EVENT (32)3110 bytesSame as WRITE_ROWS
GTID_LOG_EVENT (33)3242 bytesCommit flag, UUID, GNO, logical clock

A Note on MySQL 8.4+

MySQL 8.4 introduced GTID_TAGGED_LOG_EVENT (event type 42), which adds one more byte to the post-header length array. This means:

VersionFDE SizePost-Header ArrayEvent Types
MySQL 8.0122 bytes41 bytes41
MySQL 8.4+123 bytes42 bytes42

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.

Checksum Algorithm: 01

01 → 1 = CRC32

The checksum algorithm is CRC32. Possible values:

  • 0x00 = Checksum disabled
  • 0x01 = CRC32

When CRC32 is enabled (the default since MySQL 5.6.2), every event ends with a 4-byte checksum.

Checksum: d6331bd8

d6331bd8 → 0xd81b33d6 (little-endian)

The CRC32 checksum of the event is 0xd81b33d6. This covers the entire event from the header through the checksum algorithm byte.


Visual Breakdown

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                                     │
└──────────────┴──────────────────────────────────────────────────────────┘


Why the FDE Matters

The FORMAT_DESCRIPTION_EVENT is essential for several reasons:

  1. Forward Compatibility: New MySQL versions can add event types. The post-header length array allows parsers to skip unknown events safely. For example, MySQL 8.4 added GTID_TAGGED_LOG_EVENT (type 42), extending the array from 41 to 42 bytes.
  2. Checksum Handling: The checksum algorithm field tells us whether to expect (and validate) 4 bytes at the end of each event.
  3. Version Detection: Tools can adjust their parsing logic based on the server version string. This is particularly important when handling differences between MySQL 8.0 and 8.4+.
  4. Recovery: During crash recovery or point-in-time recovery, the FDE tells the server how to interpret the log.

Try It Yourself

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


What's Next?

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.

Want to see Readyset in action?

Book a demo and see how Readyset can accelerate your database.

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.

© 2026 Readyset. All rights reserved.