mysqlbinlog: How to Read and Analyze MySQL Binary Logs for Incident Investigation and Database Auditing

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

Up and Running in 5 Minutes

It took me nearly two hours to track down the root cause when our production DB lost 30,000 rows from the orders table — not a code bug, but someone running a DELETE without a WHERE clause. That incident was what finally made me appreciate the true value of binary logs and the mysqlbinlog tool.

First, check whether binary logging is enabled:

mysql -u root -p -e "SHOW VARIABLES LIKE 'log_bin';"

If the result shows Value = ON, you’re good to go. If it’s OFF, add the following lines to your MySQL configuration file and restart:

# /etc/mysql/mysql.conf.d/mysqld.cnf (Ubuntu/Debian)
# /etc/my.cnf.d/mysql-server.cnf (CentOS/RHEL)
[mysqld]
log_bin             = /var/lib/mysql/mysql-bin
binlog_format       = ROW
binlog_row_image    = FULL
server_id           = 1
binlog_expire_logs_seconds = 1209600   # 14 days
max_binlog_size     = 100M

List the currently available binlog files:

mysql -u root -p -e "SHOW BINARY LOGS;"
# +------------------+-----------+-----------+
# | Log_name         | File_size | Encrypted |
# +------------------+-----------+-----------+
# | mysql-bin.000001 |    524288 | No        |
# | mysql-bin.000002 |   1048576 | No        |
# +------------------+-----------+-----------+

Read the contents of the latest binlog file:

mysqlbinlog /var/lib/mysql/mysql-bin.000002

That’s all it takes — you can now view every change that has occurred in the database.

Understanding binlog_format: Why Choose ROW?

There are 3 binlog recording modes, and I tried all three before making my decision:

  • STATEMENT: Records the original SQL statement — compact but risky during replay. Functions like NOW(), UUID(), or RAND() may return different values when re-executed.
  • ROW: Records the actual data changes row by row (before/after image). The safest option — this is what I use on production.
  • MIXED: Automatically uses STATEMENT for simple SQL and switches to ROW when necessary — a reasonable compromise for read-heavy environments.

My production database runs MySQL 8.0 with around 50GB of data. I set binlog_format = ROW and binlog_row_image = FULL to capture both before and after images. It costs more disk space, but the information available during an investigation is far more complete.

The Most Practical mysqlbinlog Commands

Filter by Time Range

This is the most common use case — when you know when an incident occurred, you can zero in on that window immediately:

mysqlbinlog \
  --start-datetime="2026-06-17 14:00:00" \
  --stop-datetime="2026-06-17 14:30:00" \
  /var/lib/mysql/mysql-bin.000005

Filter by a Specific Database

mysqlbinlog --database=myapp_db /var/lib/mysql/mysql-bin.000005

Read ROW Format in Human-Readable Form

Binlog ROW format encodes data as base64 by default — not readable with the naked eye. You need to add two flags:

mysqlbinlog --base64-output=DECODE-ROWS -v \
  /var/lib/mysql/mysql-bin.000005

The output will display the before/after values for each row:

### UPDATE `myapp_db`.`orders`
### WHERE
###   @1=1234        /* id */
###   @2='pending'   /* status */
###   @3='2026-06-17 10:00:00' /* updated_at */
### SET
###   @1=1234
###   @2='completed'
###   @3='2026-06-17 14:17:05'

Read Multiple Consecutive Binlog Files

mysqlbinlog --base64-output=DECODE-ROWS -v \
  /var/lib/mysql/mysql-bin.000003 \
  /var/lib/mysql/mysql-bin.000004 \
  /var/lib/mysql/mysql-bin.000005

Read Binlog from a Remote Server

mysqlbinlog --read-from-remote-server \
  --host=192.168.1.100 \
  --user=repl_user \
  --password \
  --database=myapp_db \
  mysql-bin.000005

Practical Applications: Incident Investigation and Point-in-Time Recovery

Scenario 1 — Finding Who Deleted the Data

Scenario: The products table lost 500 rows around 15:30. Here’s the investigation workflow:

# Step 1: Identify which binlog file covers that time window
mysql -u root -p -e "SHOW BINARY LOGS;"

# Step 2: Export to a text file for easy grepping
mysqlbinlog --base64-output=DECODE-ROWS -v \
  --start-datetime="2026-06-17 15:25:00" \
  --stop-datetime="2026-06-17 15:35:00" \
  /var/lib/mysql/mysql-bin.000005 > /tmp/incident_15h.sql

# Step 3: Find DELETE statements on the products table
grep -i "DELETE.*products" /tmp/incident_15h.sql -A 5

Combined with SHOW PROCESSLIST logs or the general query log (if enabled), you can trace exactly which session executed that statement.

Scenario 2 — Point-in-Time Recovery (PITR)

This is exactly what I did to recover the 30K rows accidentally deleted from orders. The process involves two steps:

  1. Restore the most recent backup (taken at 03:00 AM)
  2. Use mysqlbinlog to replay the binlog from after the backup point up to just before the catastrophic DELETE statement
# Find the position of the DELETE to know where to stop
mysqlbinlog --base64-output=DECODE-ROWS -v \
  /var/lib/mysql/mysql-bin.000005 | grep -B 10 "DELETE FROM orders" | head -30

# Output will contain something like: # at 1234567
# That is the position to stop at

# Replay from after the backup up to just before that position
mysqlbinlog \
  --start-datetime="2026-06-17 03:00:05" \
  --stop-position=1234567 \
  /var/lib/mysql/mysql-bin.000005 | mysql -u root -p myapp_db

Key tip: Before any risky operation (bulk delete, schema migration, etc.), always run SHOW MASTER STATUS and note down the File and Position. That’s your rollback point if something goes wrong.

mysql -u root -p -e "SHOW MASTER STATUS\G"
# *************************** 1. row ***************************
#              File: mysql-bin.000005
#          Position: 1189432
#      Binlog_Do_DB:
#  Binlog_Ignore_DB:

Scenario 3 — Daily Database Activity Audit

# Export all changes for the day to a file
mysqlbinlog --base64-output=DECODE-ROWS -v \
  --start-datetime="2026-06-17 00:00:00" \
  --stop-datetime="2026-06-17 23:59:59" \
  /var/lib/mysql/mysql-bin.000005 > audit_20260617.sql

# Count the number of UPDATE/DELETE/INSERT events
echo "INSERT count: $(grep -c '^### INSERT' audit_20260617.sql)"
echo "UPDATE count: $(grep -c '^### UPDATE' audit_20260617.sql)"
echo "DELETE count: $(grep -c '^### DELETE' audit_20260617.sql)"

Advanced: Automation and Binlog Encryption

Alert Script for Large DELETE Operations

I wrote this small script to run via cron every hour, sending an alert if it detects an unusually high number of deleted rows:

#!/bin/bash
# /opt/scripts/check_large_deletes.sh

MYSQL_PASS="your_password"
THRESHOLD=500
BINLOG_PATH="/var/lib/mysql"

CURRENT_FILE=$(mysql -u root -p"$MYSQL_PASS" -Nse "SELECT @@log_bin_basename;" 2>/dev/null)
LATEST=$(ls -t ${CURRENT_FILE}.* 2>/dev/null | head -1)

[ -z "$LATEST" ] && exit 0

DELETE_COUNT=$(mysqlbinlog --base64-output=DECODE-ROWS -v \
  --start-datetime="$(date -d '1 hour ago' '+%Y-%m-%d %H:%M:%S')" \
  "$LATEST" 2>/dev/null | grep -c '^### DELETE')

if [ "$DELETE_COUNT" -gt "$THRESHOLD" ]; then
  echo "[ALERT] $DELETE_COUNT DELETE events in the past hour on $(hostname)" | \
    mail -s "MySQL Binlog Alert" [email protected]
fi

Binlog Encryption on MySQL 8.0.14+

If binlog_encryption = ON is set, you cannot read binlog files directly from disk. You must connect through the server:

mysqlbinlog --read-from-remote-server \
  --host=127.0.0.1 \
  --user=root \
  --password \
  --base64-output=DECODE-ROWS -v \
  mysql-bin.000005

Practical Tips from 6 Months on Production

  • Set an appropriate binlog retention period: I keep 14 days — enough time to investigate incidents without consuming too much disk. With 50GB of data, my binlogs generate roughly 1–2GB per day depending on traffic.
  • Filter by position instead of reading everything: If a binlog file exceeds 500MB, using --start-position and --stop-position is significantly faster than reading the entire file and grepping.
  • mysqlbinlog does not record the MySQL user: Binlog captures what changed, not who was logged in. For per-user auditing, you need to enable the MySQL Audit Log Plugin or general_log as well.
  • Always test binlog replay in a test environment first: When performing PITR, always test against a database copy first — replaying binlog directly onto production without verification can create duplicates or conflicts.
  • Combine with pt-query-digest: This Percona Toolkit tool can read binlogs and aggregate query patterns — very useful for generating weekly audit reports.

After six months operating a MySQL 8.0 production database with around 50GB of data, I think of the binlog as the flight data recorder of your database — rarely needed, but absolutely critical when you do need it. Investing the time to get comfortable with mysqlbinlog now will save you hours of stressful troubleshooting when a real incident strikes.

Share: