MySQL
Replication Internals: Decoding the MySQL Binary Log Part 7: TABLE_MAP_EVENT – Table Metadata for Row-Based Replication
11 min read
•
about 2 months ago

In this seventh post of our series, we decode the TABLE_MAP_EVENT — the event that maps a numeric table ID to a database/table name and the column layout that the row events immediately following it will reference.
Introduction
The TABLE_MAP_EVENT (event type 19, 0x13) is essential for row-based replication. It appears before any row event (INSERT, UPDATE, DELETE) and provides:
- Table identification: Database name, table name, and a numeric table ID
- Column metadata: Number of columns and their types
- Nullability: Which columns can contain NULL values
Row events reference the table by its numeric ID, so the TABLE_MAP_EVENT must be parsed first to understand the row data that follows.
Event Location
In our binary log, TABLE_MAP_EVENTs appear before each set of row events:
Let's decode the first one at position 620.
Reading the Raw Bytes
The event is 68 bytes: 19-byte header + 8-byte post-header + 37-byte payload + 4-byte checksum.
Common Header (19 bytes)
From Part 2, we know that every binary log event starts with the same 19-byte common header. Here it is for our event at position 620:
Quick cross-check: 620 + 68 = 688, which matches the Next Position field. ✓
TABLE_MAP_EVENT Structure
The layout is defined by Table_map_event in libs/mysql/binlog/event/rows_event.h and read by its constructor in libs/mysql/binlog/event/rows_event.cpp. The post-header is fixed-size (TABLE_MAP_HEADER_LEN = 8, with TM_MAPID_OFFSET = 0 and TM_FLAGS_OFFSET = 6); the body that follows is variable.
| Field | Size | Description |
|---|---|---|
| Post-Header (8 bytes) | ||
m_table_id | 6 bytes | Numeric identifier for the table (little-endian, 48-bit) |
m_flags | 2 bytes | Bit flags — see TM_*_F constants in sql/log_event.h |
| Body | ||
m_dblen | Packed int | Length of database name |
m_dbnam | m_dblen + 1 | Database name + trailing \0 |
m_tbllen | Packed int | Length of table name |
m_tblnam | m_tbllen + 1 | Table name + trailing \0 |
m_colcnt | Packed int | Number of columns |
m_coltype | m_colcnt bytes | One MySQL type code per column |
m_field_metadata_size | Packed int | Length of the field metadata block |
m_field_metadata | Variable (≤ 4 bytes per column) | Per-column type-specific metadata |
m_null_bits | (m_colcnt + 7) / 8 bytes | LSB-first bit per column; 1 = nullable |
m_optional_metadata | Variable | TLV-encoded extended column information |
Field-by-Field Decoding
Post-Header (8 bytes)
The post-header is exactly 6 bytes of m_table_id followed by 2 bytes of m_flags — no padding. The constructor in rows_event.cpp reads the table id with reader.read<uint64_t>(6) (note the explicit length of 6), then reads m_flags with reader.read<uint16_t>().
Table ID
The numeric handle for the table. This same 95 will appear in the post-header of every row event (WRITE_ROWS_EVENT, UPDATE_ROWS_EVENT, DELETE_ROWS_EVENT) that follows in this transaction, telling the applier which TABLE_MAP_EVENT it should use to interpret the row payload.
Flags
m_flags is a 16-bit field. The values are defined on Table_map_log_event in sql/log_event.h:2471-2481:
| Mask | Constant | Meaning |
|---|---|---|
0x0001 | TM_BIT_LEN_EXACT_F | Always set when MySQL writes the event |
0x0002 | TM_REFERRED_FK_DB_F | Table is referenced by a foreign-key constraint in another schema |
0x0004 | TM_GENERATED_INVISIBLE_PK_F | Table has a server-generated invisible primary key |
Our event has just TM_BIT_LEN_EXACT_F set, which is the normal case. ✓
Database Name
Database: "presentation"
Table Name
Table: "person"
Column Count
This is a net_field_length_ll packed integer (the same length encoding introduced in Part 1). Since 0x02 < 251, the value is a single byte: 2 columns.
Column Types (2 bytes, one per column)
Our table has:
- Column 1:
ID INT→ Type 3 (MYSQL_TYPE_LONG) - Column 2:
name VARCHAR(150)→ Type 15 (MYSQL_TYPE_VARCHAR)
Metadata Length
The metadata block is 2 bytes long.
Column Metadata
Each column writes 0–4 bytes of metadata, depending on its type. The encoding lives in each Field subclass's do_save_field_metadata() in sql/field.cc. For our two columns:
MYSQL_TYPE_LONG(INT, 0x03) — 0 bytes. Plain integers carry no metadata.MYSQL_TYPE_VARCHAR(0x0f) — 2 bytes.Field_varstring::do_save_field_metadata()simply doesint2store(metadata_ptr, field_length), wherefield_lengthis the column's max length in bytes (not characters).
Reading 58 02 as little-endian: 0x0258 = 600. For name VARCHAR(150) declared in a utf8mb4 schema, MySQL reserves 4 bytes per character, so field_length = 150 × 4 = 600. The same column declared as VARCHAR(150) CHARACTER SET latin1 would store 150 here instead — the metadata is always max bytes, never max characters.
Null Bitmap
The null bitmap tells us which columns of the table are allowed to hold NULL. It uses one bit per column, packed into as few bytes as possible.
Since a byte holds 8 bits, a single byte can describe up to 8 columns. The moment you have a 9th column you need a second byte (even though only 1 bit of it is actually used), the 17th column triggers a third byte, and so on. In other words, you take the number of columns, divide by 8, and round up to the next whole byte:
Our table has 2 columns, so (2 + 7) / 8 = 1 byte.
Bits inside each byte are read starting from the least significant bit (the rightmost one when you write the byte in binary): the first column sits at bit 0, the second column at bit 1, the third at bit 2, and so on. A bit set to 1 means that column is nullable; a bit set to 0 means it was declared NOT NULL.
Our byte is 0x02, which is 0000 0010 in binary:
That matches the DDL exactly: ID is the PRIMARY KEY (so implicitly NOT NULL), and name was declared DEFAULT NULL.
One important distinction worth calling out: this bitmap only records whether each column can hold NULL — it describes the schema, not the data. Whether a given row actually contains a NULL value for a particular column is answered by a second, per-row null bitmap that lives inside each row of a WRITE_ROWS_EVENT / UPDATE_ROWS_EVENT / DELETE_ROWS_EVENT. We'll meet that one in Part 8.
If you want to see the exact bit-extraction pattern used on the read side, it's in sql/log_event.cc:11735.
Optional Metadata
After m_null_bits, the rest of the body (everything up to the CRC32) is the optional metadata block. Each entry is a Type-Length-Value triple:
The full set of types is the Optional_metadata_field_type enum in rows_event.h:548-573:
| Code | Type | Purpose |
|---|---|---|
| 1 | SIGNEDNESS | Sign bit for each numeric column |
| 2 | DEFAULT_CHARSET | Default charset + per-column overrides |
| 3 | COLUMN_CHARSET | One charset per string column |
| 4 | COLUMN_NAME | Column names |
| 5 | SET_STR_VALUE | String values for SET columns |
| 6 | ENUM_STR_VALUE | String values for ENUM columns |
| 7 | GEOMETRY_TYPE | Concrete geometry subtype |
| 8 | SIMPLE_PRIMARY_KEY | PK column indices |
| 9 | PRIMARY_KEY_WITH_PREFIX | PK columns with prefix lengths |
| 10 | ENUM_AND_SET_DEFAULT_CHARSET | Default charset for ENUM/SET |
| 11 | ENUM_AND_SET_COLUMN_CHARSET | Per-column charset for ENUM/SET |
| 12 | COLUMN_VISIBILITY | Visible/invisible flag per column |
| 13 | VECTOR_DIMENSIONALITY | Dimensionality of VECTOR columns |
Which entries appear depends on the table definition and on binlog_row_metadata (MINIMAL vs. FULL). Our 8 bytes contain two entries:
Entry 1 — SIGNEDNESS
This field uses one bit per numeric column in the table (string, blob, and date/time columns are skipped). A bit set to 1 means the column was declared UNSIGNED; a bit set to 0 means it's signed.
There's an important subtlety here. When we decoded the null bitmap earlier in this post, we read the bits right-to-left — the first column lived at the rightmost bit of the byte. SIGNEDNESS does the opposite: it reads left-to-right, starting from the leftmost bit of the first byte. So the first numeric column is the leftmost bit, the second numeric column is the next bit to its right, and so on. When we run out of bits in one byte, we move on to the leftmost bit of the next byte. You can see this walk in parse_signedness().
Our table has exactly one numeric column (ID INT), and the value byte is 0x00:
The leftmost bit — the one belonging to ID — is 0, so ID is signed. That matches the DDL: ID INT was declared without the UNSIGNED keyword.
Entry 2 — DEFAULT_CHARSET
parse_default_charset() in rows_event.cpp:142-158 reads a packed integer for the default charset id, then walks (col_index, charset) pairs until it consumes length bytes.
- First packed int:
fc ff 00. The leading0xfcis the 2-byte marker fornet_field_length_ll, so the next two bytesff 00are the value:0x00ff= 255 =utf8mb4_0900_ai_ci. - That fully consumes the 3-byte value, so there are no per-column overrides — every string column uses the default.
Together, the 8 bytes tell the applier: "the only numeric column is signed, and every string column is utf8mb4_0900_ai_ci."
Visual Breakdown
Table ID Lifecycle
The Table ID is assigned by the server and is valid only within a single binary log file or replication session. Key points:
- Transient: The same table may have different IDs in different binary logs
- Referenced by row events: WRITE/UPDATE/DELETE_ROWS_EVENT use this ID
- Reused after FLUSH TABLES: Table IDs may be reassigned
When parsing row events, you must first parse the preceding TABLE_MAP_EVENT to know how to interpret the row data.
Try It Yourself
First, dump the bytes with xxd:
Then decode it programmatically. The script below walks the post-header, body, and TLV optional-metadata block end-to-end:
Output:
The full source for this series, including a more complete decoder, lives in the presentations repo.
References
Table_map_event— class definition,TM_MAPID_OFFSET/TM_FLAGS_OFFSETenum, and theOptional_metadata_field_typeenumTable_map_log_eventTM_*_Fflags —TM_BIT_LEN_EXACT_F,TM_REFERRED_FK_DB_F,TM_GENERATED_INVISIBLE_PK_F(defined insql/log_event.h, not in the binlog-events library)TABLE_MAP_HEADER_LEN— post-header length constant (= 8)Table_map_event::Table_map_event(const char*, ...)— the read pathTable_map_event::Optional_metadata_fields— parser for the TLV optional metadata blockparse_signedness()andparse_default_charset()Table_map_log_event::write_data_header()andwrite_data_body()— the write pathTable_map_log_event::init_metadata_fields()— how the optional metadata block is populatedField_varstring::do_save_field_metadata()— 2-byte field metadata forVARCHARTable_id— 6-byte (48-bit) table identifier- MySQL docs:
binlog_row_metadata— controls which optional metadata fields get written
What's Next?
Now that we understand how table metadata is encoded, we're ready to decode actual row data. In the next post, we'll examine the WRITE_ROWS_EVENT — the event that records INSERT operations.
Next up: Part 8: WRITE_ROWS_EVENT — INSERT Operations
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.
Authors