Setting a Safe binlog_expire_logs_seconds for Compliance Retention

A single under-scoped timer value is the most common way an operator turns a compliance requirement into an outage. Set binlog_expire_logs_seconds too low and a lagging or rebuilding replica halts with error 1236 — “Cannot replicate because the master purged required binary logs” the instant the primary expires a segment the replica still needed; the GTID chain fractures and your only fix is a full replica rebuild. Set it with no capacity math and the opposite failure arrives: the datadir volume fills, commits stall, and the instance crashes with Disk is full. This page resolves the narrow but high-stakes decision of choosing one value that simultaneously satisfies a regulatory retention window (SOC 2, HIPAA, PCI-DSS, GDPR) and never purges a log a downstream consumer or Point-in-Time Recovery (PITR) replay still requires — using MySQL 8.0’s second-precision binlog_expire_logs_seconds rather than the removed expire_logs_days.

Visual Overview

Computing a safe binlog_expire_logs_seconds and where it sits on the retention axisThe safe value is max(compliance floor, worst lag + archival latency) plus a 24-hour buffer, giving 2,678,400 seconds (31 days). On the retention axis, anything below the 30-day compliance floor purges logs still needed (ERROR 1236, non-compliant); the 24-hour band above it is safety margin; the value is set at 31 days; beyond a headroom region the datadir fills and stalls.1 — Compute the valueCompliance floor30 d = 2,592,000 sWorst replica lag +archival latencymax()pick larger+ 24 h buffer+86,400 sbinlog_expire_logs_seconds= 2,678,400 s  (31 d)2 — Where that value sits on the retention axisSET 2,678,400 s (31 d)TOO LOWpurges logs still needed → ERROR 1236SAFE BAND24 h marginHEADROOMstored, awaiting rotationTOOHIGHcompliance floor · 30 ddisk pressure beginsThe buffer is the only margin between a compliance breach and a full volume — never size to the bare floor.
The safe timer is max(compliance floor, worst lag + archival latency) + 24 h buffer. Set below the compliance floor and expiry breaks the replay chain (ERROR 1236); set beyond your stored capacity and the datadir fills — the 24-hour band is the deliberate margin between the two.

Context & Prerequisites

The server timer is a backstop, not your primary retention control. A precise, recovery-safe purge boundary is computed from transaction state — the minimum of the replication, archival, compliance, and backup floors — by the engine described in Binlog Retention Boundaries; binlog_expire_logs_seconds exists only to catch segments that pipeline already confirmed are safe. Its job is therefore to be set longer than your worst-case archival latency and replica lag, and to encode the compliance floor — the regulatory minimum you may never purge below. Because continuity is measured in Global Transaction Identifiers rather than file positions, this only holds when every node shares one identifier space: require MySQL 8.0.22+ with gtid_mode = ON and enforce_gtid_consistency = ON, extracted gap-free by the GTID Tracking & Enforcement pipeline, and understand the file/GTID model from MySQL Binary Log Architecture & GTID Fundamentals. The value also interacts with storage volume: under ROW vs STATEMENT vs MIXED Formats, the ROW format required for deterministic PITR replay generates 3–10× more log volume than STATEMENT, so a long compliance window is also a large storage commitment.

Step-by-Step Implementation

Each step below is annotated with its PITR relevance — why the number you pick either preserves or destroys the ability to replay to an arbitrary point.

1. Convert the compliance window to exact seconds

Regulatory frameworks specify days; the variable takes seconds. The conversion is deterministic:

seconds = retention_days × 86,400

A 30-day audit window is 30 × 86,400 = 2,592,000 seconds. PITR relevance: this is the floor below which the value must never drop — it is the minimum span over which an auditor (or a recovery) can reconstruct state, so it is a hard lower bound on the timer, never the final answer.

2. Measure worst-case replica lag and archival latency

The safe value is the compliance floor or the slowest consumer’s catch-up window plus your archival latency — whichever is larger. Measure the current worst case before committing a number:

-- MySQL 8.0.22+ : worst replica lag, run on each replica (or via a monitoring roll-up)
SELECT
    CHANNEL_NAME,
    SERVICE_STATE,
    LAST_QUEUED_TRANSACTION,
    LAST_APPLIED_TRANSACTION
FROM performance_schema.replication_applier_status_by_worker;

-- Coarse lag signal from the replica's status
SHOW REPLICA STATUS\G   -- read Seconds_Behind_Source

PITR relevance: a replica lagging Seconds_Behind_Source = 900 still needs every segment back to its applied GTID. If the timer expires one of those, the replica cannot resume from SOURCE_AUTO_POSITION = 1 and the recovery chain that depends on that replica is gone. Add a safety buffer of at least 24 hours (86,400 seconds) over the measured worst case to absorb spikes and archival upload delays.

3. Apply dynamically, then persist

Set the value on the running instance first to avoid a restart, then persist it so it survives service lifecycle events:

-- MySQL 8.0.22+
-- 30-day compliance floor (2,592,000) + 24h buffer (86,400) = 2,678,400
SET GLOBAL  binlog_expire_logs_seconds = 2678400;   -- takes effect now
SET PERSIST binlog_expire_logs_seconds = 2678400;   -- writes mysqld-auto.cnf, survives restart

PITR relevance: SET GLOBAL alone is lost on restart — a node that reboots reverts to the compiled/my.cnf default (2592000, 30 days) and may silently start purging on a tighter schedule than compliance requires. SET PERSIST closes that drift window. Note that changing the value does not retroactively rewrite the age of existing logs; expiry is evaluated against each file’s last-modified time at the next opportunity.

4. Forecast disk and set a guardrail

A long window is a storage commitment. Measure daily generation, then reserve headroom:

-- MySQL 8.0.22+ : cumulative binlog I/O for growth-rate estimation
SELECT
    EVENT_NAME,
    SUM_NUMBER_OF_BYTES_WRITE
FROM performance_schema.file_summary_by_instance
WHERE EVENT_NAME LIKE 'wait/io/file/sql/binlog%';

-- Current position / active file (8.4 name; use SHOW MASTER STATUS on 8.0)
SHOW BINARY LOG STATUS\G

Reserve at least 30% free space after projecting the full retention window: an instance writing 50 GB/day held for 30 days needs 1,500 GB × 1.3 ≈ 1,950 GB of dedicated volume. PITR relevance: if the volume fills before the timer expires anything, MySQL stalls and can crash — a longer window that you cannot store is not safer, it is an outage waiting for its first busy day. Mount binary logs on a volume separate from the datadir so log growth cannot starve the tablespace.

5. Archive every segment before it can expire

The timer must never be the reason a recoverable transaction disappears. Stream each completed segment to durable storage — verified — before expiry can reach it. This is the transform stage detailed in Compression & Encryption Workflows; a minimal streaming extractor:

# Python 3.10+
import asyncio
from dataclasses import dataclass
from pathlib import Path


@dataclass(slots=True, frozen=True)
class ArchiveResult:
    log_name: str
    dest: Path
    ok: bool


async def archive_binlog(log_name: str, dest_dir: Path) -> ArchiveResult:
    """Stream one binary log off the primary in raw form, ready for verify + upload."""
    dest = dest_dir / log_name
    proc = await asyncio.create_subprocess_exec(
        "mysqlbinlog",
        "--read-from-remote-server",
        "--host=primary.internal",
        "--user=binlog_reader",
        "--raw",
        "--verify-binlog-checksum",     # fail fast on silent corruption
        "--result-file", f"{dest_dir}/",
        log_name,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    _, stderr = await proc.communicate()
    match proc.returncode:
        case 0:
            return ArchiveResult(log_name, dest, ok=True)
        case code:
            raise RuntimeError(f"extract of {log_name} failed ({code}): {stderr.decode()}")

PITR relevance: once a segment is checksum-verified in object storage, expiring its local copy costs nothing recoverable — replay can pull it back. Pass the credential via a secrets manager rather than the command line; see Security & Access Frameworks. Run extraction against a replica, not the primary, to keep archival I/O off the write path.

Configuration Reference

A minimal, copy-pasteable my.cnf block for a 30-day compliance window with a 24-hour buffer:

[mysqld]
log_bin                        = mysql-bin
binlog_format                  = ROW
binlog_expire_logs_seconds     = 2678400   # 31 days (30d compliance + 24h buffer)
sync_binlog                    = 1         # crash-safe durability of retained logs
innodb_flush_log_at_trx_commit = 1
# expire_logs_days is intentionally left unset (removed in MySQL 8.4)
VariableTypeDefaultRecommendedPITR impact
binlog_expire_logs_secondsinteger (sec)2592000 (30 d)max(compliance, worst_lag + archival_latency) + 86400The backstop expiry timer; too low breaks the replay chain, too high risks disk exhaustion
expire_logs_daysinteger (days)0 (removed in 8.4)leave 0 / unsetLegacy day-granular timer; setting it and the seconds variable non-zero is a conflict — the days value is ignored with a warning
binlog_formatenumROWROWDeterministic replay required for forensic, byte-reproducible PITR
binlog_row_imageenumFULLMINIMAL (with audit sign-off)Shrinks retained volume 30–60%; confirm forensic column needs first
sync_binloginteger11Guarantees the log you are retaining is actually durable after a crash
log_binbooleanONONBinary logging must be enabled for any retention window to exist

Verification Checklist

Gotchas & Version-Specific Caveats

Purging is event-driven, not a 300-second poll. A widespread myth is that a background thread scans every five minutes. MySQL evaluates expiry at server startup and on each binary log rotation (a size-triggered switch, an explicit FLUSH BINARY LOGS, or FLUSH LOGS). On a quiet instance that rarely rotates, expired logs can linger well past their timestamp; on a busy one they clear promptly. Do not expect immediate disk reclamation the moment you shorten the window — force a rotation if you need space back sooner.

expire_logs_days is gone in MySQL 8.4. It was deprecated in 8.0 and removed in 8.4.0. Configs carried forward from 5.7/8.0 that still set it will fail to start on 8.4. On 8.0, if both expire_logs_days and binlog_expire_logs_seconds are non-zero, the seconds value wins and the days value is ignored with a warning — set only the seconds variable.

SHOW MASTER STATUS was renamed. It is deprecated as of MySQL 8.2.0 and replaced by SHOW BINARY LOG STATUS in 8.4; RESET MASTER likewise became RESET BINARY LOGS AND GTIDS. Automation that parses the old command breaks silently after an 8.4 upgrade — pin the statement to the server version.

Never delete binary log files from the filesystem. Removing mysql-bin.00xxxx by hand corrupts the binlog.index file and desynchronizes gtid_purged, forcing a full recovery. Reclaim space only through PURGE BINARY LOGS TO 'log_name' or the expiry timer. If a purge targets a name a concurrent rotation already dropped, you get ERROR 1373 (ER_UNKNOWN_TARGET_BINLOG) — re-read SHOW BINARY LOGS and recompute rather than hard-coding a target.

A tight timer during a replica rebuild is a trap. If you are reprovisioning a replica from backup, its needed GTID range extends far back in time. Temporarily raise binlog_expire_logs_seconds (or gate purging through the Binlog Retention Boundaries engine) until the rebuild catches up, or route around the gap using Fallback Routing Strategies. Otherwise the timer can expire the very logs the rebuild is streaming.

Frequently Asked Questions

Should binlog_expire_logs_seconds equal my compliance window exactly?

No — treat the compliance window as the floor, not the target. The safe value is the larger of the compliance seconds and your worst-case replica lag plus archival latency, then a 24-hour buffer on top. Sizing it to the bare regulatory minimum leaves no margin: one slow replica or one delayed upload and the timer purges a log something still needed.

Does lowering the value delete old logs immediately?

No. Expiry is evaluated at startup and on each binary log rotation, not on a continuous timer, and it acts on files whose last-modified time is older than the window. On a low-write instance that rarely rotates, old logs can persist past their nominal expiry. Run FLUSH BINARY LOGS to trigger an evaluation if you need the space reclaimed sooner.

Can I still use expire_logs_days for day-granular control?

Not on MySQL 8.4 — it was removed there. On 8.0 it is deprecated, and if you set it alongside binlog_expire_logs_seconds, the seconds variable takes precedence and the days value is ignored with a warning. Standardize on binlog_expire_logs_seconds everywhere so a future 8.4 upgrade does not break your config.

How do I prove the setting to an auditor?

Read it back live with SELECT @@GLOBAL.binlog_expire_logs_seconds;, confirm it is persisted in mysqld-auto.cnf, and show a drift alert that fires when the running value deviates from the approved baseline. Pair that with the archival manifest proving every segment inside the window is verified in durable storage — the timer plus the archive together are the evidence.

Back to Binlog Retention Boundaries.