Best Practices for Log Rotation in Python

Reliable log rotation keeps a long-running Python service from exhausting its disk, losing records during rollover, or stalling request threads on rename and metadata work. This walkthrough is for backend engineers and SREs who already write to a file sink and now need that sink to survive weeks of production traffic and several worker processes. It sits inside the handler architecture guide, part of the Python Logging Fundamentals and Structured Data reference.

The rename cascade inside doRollover() Before rollover, service.log has reached maxBytes alongside service.log.1 and service.log.2. After doRollover with backupCount set to two, service.log is renamed to service.log.1, the old service.log.1 becomes service.log.2, the old service.log.2 is unlinked because it falls beyond backupCount, and a fresh empty service.log is opened. Before — service.log has reached maxBytes service.log 50 MiB, active service.log.1 previous roll service.log.2 oldest kept rename shift unlink service.log new, empty service.log.1 was service.log service.log.2 was .log.1 unlinked beyond backupCount After doRollover() with backupCount = 2 — a fresh service.log is opened
Size-based rollover renames and shifts numbered backups, discarding anything beyond backupCount — backupCount filesystem operations per roll.

Prerequisites

Rotation needs no third-party packages — logging.handlers ships with CPython — but the fcntl locking below is POSIX-only (Linux and macOS). Pin the interpreter rather than a library:

# Standard library only. Verified on CPython 3.10-3.13; 3.12.x used throughout.
python --version   # Python 3.12.x
python -c "import fcntl, logging.handlers; print('rotation prerequisites OK')"

Expected Output:

Python 3.12.7
rotation prerequisites OK

Set a writable log directory and an explicit umask so log-shipping agents can read the archives:

export APP_LOG_DIR="/var/log/app"
umask 0022

The queue pattern used in Step 3 is covered in depth in non-blocking logging with QueueHandler, and the JSON formatter shown here follows the conventions in structured logging with the Python standard library.

Implementation

Step 1 — Choose the handler by constraint. Use RotatingFileHandler when disk capacity is the binding limit; it caps each file at maxBytes and keeps backupCount archives. Use TimedRotatingFileHandler when retention is defined in time, setting when="midnight" and interval=1 for daily audit windows. Use WatchedFileHandler when the operating system's rotator owns the trigger. Time-based rotation depends on the system clock and only rolls over on the next emit, so an idle process can defer a rollover and then spike disk usage when traffic resumes.

Step 2 — Serialize rollover with an advisory lock. The stock RotatingFileHandler has no multi-process safety: concurrent doRollover() calls interleave lines and can raise OSError: [Errno 11] Resource temporarily unavailable. Subclass it to take an exclusive fcntl.flock while the stream is open and release it before the rename so the rollover is atomic per worker.

Step 3 — Keep rollover off the request path. Rollover performs file renames and metadata updates that are slow under load. Place the rotating handler behind a QueueHandler/QueueListener so the request thread only enqueues and the background listener absorbs the rollover latency.

Rotation behind a QueueHandler and QueueListener On the request side, the request thread calls logger.info, a QueueHandler puts the record on a bounded queue.Queue and returns immediately. Across the thread boundary, a QueueListener drains the queue into a SafeRotatingFileHandler that holds an fcntl exclusive lock and performs the rename cascade on service.log, so the request thread never waits on rollover. Request path — enqueue and return request thread logger.info(...) QueueHandler put_nowait, returns queue.Queue maxsize=10000 thread boundary — rollover cost stays below handoff QueueListener background thread SafeRotatingFileHandler holds fcntl.LOCK_EX service.log + rename cascade doRollover() runs here — on the listener thread the request thread never waits on a rename or an unlink
The queue is the thread boundary: enqueueing is O(1) on the request path, while the rename cascade and the flock live entirely on the listener side.
import fcntl
import json
import logging
import queue
from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener

log_queue: queue.Queue = queue.Queue(maxsize=10000)  # bounded to cap memory


class JSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "message": record.getMessage(),
            "module": record.module,
            "trace_id": getattr(record, "trace_id", None),
            "span_id": getattr(record, "span_id", None),
        }
        return json.dumps(payload, separators=(",", ":"))


class SafeRotatingFileHandler(RotatingFileHandler):
    """RotatingFileHandler that holds an exclusive flock during writes."""

    def _open(self):
        stream = super()._open()
        fcntl.flock(stream.fileno(), fcntl.LOCK_EX)  # block until exclusive
        return stream

    def doRollover(self):
        if self.stream:
            fcntl.flock(self.stream.fileno(), fcntl.LOCK_UN)  # release before rename
            self.stream.close()
            self.stream = None
        super().doRollover()  # rename + shift backups, then reopen (re-locks)


def setup_production_logger(log_path: str = "/var/log/app/service.log"):
    handler = SafeRotatingFileHandler(
        filename=log_path,
        maxBytes=50 * 1024 * 1024,  # 50 MiB per file
        backupCount=5,              # keep 5 archives, ~300 MiB ceiling
        encoding="utf-8",
        delay=True,                 # defer open until first emit
    )
    handler.setFormatter(JSONFormatter())

    listener = QueueListener(log_queue, handler, respect_handler_level=True)
    listener.start()

    logger = logging.getLogger("app")
    logger.setLevel(logging.INFO)
    logger.addHandler(QueueHandler(log_queue))
    logger.propagate = False
    return logger, listener


if __name__ == "__main__":
    logger, listener = setup_production_logger()
    logger.info(
        "Payment processed",
        extra={"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
               "span_id": "00f067aa0ba902b7"},
    )
    listener.stop()  # flush queue and release the lock on shutdown

Expected Output: one JSON object per line in the active file, with the trace context carried through from extra.

{"timestamp":"2026-07-25 10:00:00,123","level":"INFO","message":"Payment processed","module":"__main__","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}

Step 4 — Wire it up declaratively once it works. Prototype in code, then move the final shape into a dictionary configuration so the rotation parameters live beside the rest of your logging setup; see configuring logging with dictConfig for the schema and the () custom-factory key that a handler subclass such as SafeRotatingFileHandler needs.

Step 5 — Validate under load. Drive enough volume to force several rollovers, then confirm the numbered backups appear in sequence and no line is truncated or interleaved. The verification section below gives the exact checks.

How the rotation handlers work internally

RotatingFileHandler and TimedRotatingFileHandler share a parent (BaseRotatingHandler) and differ only in when they decide to roll. On every emit, the handler calls shouldRollover(record). For the size-based handler that check formats the record, seeks to the end of the stream, and compares stream.tell() + len(message) against maxBytes; if the next write would cross the limit, it calls doRollover() first. doRollover() closes the current stream, then renames service.log to service.log.1, shifting service.log.1 to .2 and so on up to backupCount, deleting anything past it, and finally reopens a fresh empty service.log. The rename cascade is backupCount filesystem operations, which is why rollover is the expensive moment and why it must stay off the request path.

shouldRollover(): size trigger versus time trigger Both handlers run shouldRollover inside emit. RotatingFileHandler compares stream.tell() plus the formatted message length against maxBytes. TimedRotatingFileHandler compares time.time() against self.rolloverAt. Whichever test is true, the same doRollover runs: close the stream, rename the numbered backups, unlink the oldest and reopen. The size trigger bounds bytes but not age; the time trigger bounds age but not bytes. RotatingFileHandler shouldRollover() — size check TimedRotatingFileHandler shouldRollover() — clock check handler.emit(record) handler.emit(record) stream.tell() + len(msg) > maxBytes time.time() >= self.rolloverAt true true doRollover() close · rename .log → .1 → … → .N · unlink oldest · reopen bounds bytes on disk, not age bounds age, not bytes on disk
Both handlers share BaseRotatingHandler and the same doRollover(); only the predicate differs — a byte count versus a clock reading, each bounding one dimension and leaving the other free.

TimedRotatingFileHandler computes a rolloverAt timestamp at construction from when and interval, and shouldRollover simply tests time.time() >= rolloverAt. There are two consequences engineers underestimate. First, rotation is emit-driven: the file rolls only when a record arrives after the deadline, so a process that goes idle over a boundary defers its rollover and can then write a single oversized file when traffic resumes. Second, the handler tracks time, not size, so a traffic spike inside one interval can produce a backup far larger than any size cap — pair backupCount with monitoring rather than assuming time-based rotation bounds file size. Both handlers also support utc=True (timed) so boundaries do not drift with daylight-saving changes, and both honor delay=True to defer the initial open until the first emit.

One subtlety worth knowing before you tune levels: shouldRollover runs inside emit, which means a chatty DEBUG logger performs the size check on every suppressed-looking record that still reaches the handler. Keep handler levels tight — the severity contract described in how to configure Python logging for production is also a rotation-cost decision, not only a noise decision.

Multi-process rotation hazards

Neither stock handler is safe across processes. When several workers each hold an open descriptor to service.log and one calls doRollover(), it renames the file out from under the others; the workers that did not rotate keep writing to the now-renamed inode, so their records land in service.log.1 while the rotating worker writes a fresh service.log. Concurrent doRollover() calls can also collide on the rename cascade and raise FileExistsError or, with the advisory lock contended, OSError: [Errno 11] Resource temporarily unavailable. The flock-based subclass above serializes writes and rollover within a single host, which is the right fix when one machine runs several workers. When workers must instead funnel every record to one writer, the single-writer model in thread-safe logging in multiprocessing removes the contention entirely by leaving only one process holding the file.

Multi-process rollover with and without an advisory lock On the left, four workers each hold an open descriptor. Worker one rotates and writes to a fresh service.log, but workers two to four still hold the old descriptor, so their records land in the renamed service.log.1. On the right, all four workers take the same exclusive fcntl.flock, so only one writer proceeds at a time, doRollover runs alone, and service.log receives no interleaved or lost lines. Unsynchronized workers one rotates, three keep the old fd w1 w2 w3 w4 service.log new inode · w1 service.log.1 old inode · w2-w4 three fds still point at the renamed inode their records land in service.log.1 Serialized by fcntl.flock one holder, one rotator w1 w2 w3 w4 fcntl.flock(fd, LOCK_EX) one writer at a time doRollover() runs alone service.log no interleaved or lost lines
Without serialization the rename moves the file out from under three of four descriptors; an exclusive flock held across write and rollover leaves exactly one rotator per host.

When the operating system owns rotation instead, use WatchedFileHandler. It never rotates; on every emit it stats the path and compares the current inode and device against the descriptor it holds. If an external rotator (logrotate, journald) has moved or recreated the file, the inode changes, the handler reopens the path, and writing continues into the new file with no lost records — exactly the failure mode that copytruncate cannot avoid. The pairing is: logrotate with create and a postrotate SIGHUP, plus WatchedFileHandler on the Python side, so rotation and reopening are cleanly separated across the process boundary.

import logging
from logging.handlers import WatchedFileHandler

# Python writes; logrotate rotates; WatchedFileHandler reopens on inode change.
handler = WatchedFileHandler("/var/log/app/service.log", encoding="utf-8")
handler.setFormatter(JSONFormatter())
logging.getLogger("app").addHandler(handler)

Expected Output:

# After `logrotate --force`, writes continue into the new inode with no gap:
service.log        # new, active (current inode)
service.log.1.gz   # rotated by logrotate

If your deployment target is a container rather than a VM, the cheapest correct answer is often no rotation at all: write JSON to stdout, let the runtime rotate, and reserve file rotation for hosts you actually own. Third-party libraries take the opposite bet — Loguru builds rotation and retention into the sink itself, as covered in Loguru configuration and sinks — which is worth comparing before you maintain a handler subclass of your own.

Disk-space and retention math

Sizing rotation is arithmetic, not guesswork. With RotatingFileHandler, the worst-case footprint per process is maxBytes * (backupCount + 1) — the active file plus its archives. The configuration above (maxBytes=50 MiB, backupCount=5) caps one process at roughly 300 MiB; an eight-worker host therefore needs about 2.4 GiB of headroom for that one logger, before compression. If logrotate gzips archives, assume a 6–10x reduction on JSON logs and budget for the brief window where both the uncompressed and compressed copies exist during postrotate.

Storage ceiling: size rotation versus time rotation With maxBytes of 50 MiB and backupCount of 5, one process holds the active service.log plus five archives — a fixed 300 MiB, which is 2.4 GiB across eight workers. With TimedRotatingFileHandler the daily file size follows throughput instead: roughly 2 GiB on a quiet day, 6.4 GiB on a typical day and 17 GiB during a spike, so backupCount bounds the number of files but not the bytes. RotatingFileHandler hard ceiling per process service.log · 50 MiB .log.1 · 50 MiB .log.2 · 50 MiB .log.3 · 50 MiB .log.4 · 50 MiB .log.5 · 50 MiB 300 MiB maxBytes × (backupCount + 1) × 8 workers = 2.4 GiB per host a number you choose up front TimedRotatingFileHandler no ceiling — size follows throughput 2 GiB 6.4 GiB 17 GiB quiet day typical day spike day backupCount bounds file count, not bytes
Size rotation gives a footprint you can reserve; time rotation gives a retention window whose bytes are whatever the traffic produced — daily_bytes = rate × avg_record_bytes × 86400.

For TimedRotatingFileHandler, size is a function of throughput rather than a hard cap. Estimate average bytes per record (a structured JSON line carrying trace context, as produced when adding trace IDs to log records, is commonly 300–600 bytes), multiply by records per second and the interval length, and that is the expected daily file. The formula daily_bytes = rate * avg_record_bytes * 86400 turns an SLO ("retain 14 days") into a concrete reservation: with 200 records/second at 400 bytes, a day is roughly 6.4 GiB and a 14-day backupCount reserves about 90 GiB uncompressed. Always set backupCount to a finite number; the default of 0 keeps every archive forever and is the most common cause of a disk filling silently weeks after deploy.

Configuration options

Parameter Handler Purpose Production guidance
maxBytes RotatingFileHandler Size threshold to roll 10–100 MiB; 0 disables size rotation
backupCount both Archives retained Sets the storage ceiling with maxBytes; never leave at 0
when / interval TimedRotatingFileHandler Time-based trigger "midnight", 1 for daily windows
utc TimedRotatingFileHandler Boundary timezone True so DST shifts do not move the boundary
encoding both File text encoding Always "utf-8"
delay both Defer file open True to avoid opening files in idle workers

Verification

Force a rollover with a tight write loop and inspect the resulting files:

ls -1 /var/log/app/

Expected Output:

service.log
service.log.1
service.log.2

Every line in the active file must still parse as JSON after a rollover — a truncated final line is the signature of an unsynchronized writer:

python -c "import sys,json; [json.loads(l) for l in open('/var/log/app/service.log')]; print('all lines valid JSON')"

Expected Output:

all lines valid JSON

Confirm no file-descriptor leak after repeated rollovers; the count should stay flat across dozens of rolls:

ls /proc/self/fd | wc -l

Finally, turn on the logging module's own diagnostics (logging.raiseExceptions = True, the default) during the load test so a failed rename surfaces on stderr instead of being swallowed.

Common mistakes

Using copytruncate with OS logrotate on a Python process Error signature: the log file is non-empty on disk but a tail -f shows a gap of several seconds after each rotation, and file offsets in lsof are far larger than the file size. Root cause: Python keeps the original descriptor open; copytruncate copies then truncates in place, so the process keeps writing at its old offset into a sparse hole. Remediation: configure logrotate with create plus a postrotate SIGHUP, and use WatchedFileHandler (or reopen the handler on that signal) so the descriptor follows the new inode.

copytruncate versus create plus postrotate SIGHUP With copytruncate the inode is unchanged, so the Python process keeps its descriptor at offset 8 MiB: the file size resets to zero but new writes resume at the old offset, leaving a sparse hole and a visible gap in tail -f. With create plus a postrotate SIGHUP, logrotate renames the old file and creates a new inode, and WatchedFileHandler notices the inode change on its next stat and reopens the path, so no record is lost. copytruncate same inode, fd keeps its offset python process · fd at 8 MiB offset 8 MiB sparse hole new writes size resets to 0, the fd offset does not tail -f shows a gap after every roll create + postrotate SIGHUP new inode, handler reopens WatchedFileHandler · stat() per emit logrotate: rename + create postrotate → kill -HUP service.log · new inode handler reopens, no records lost
The inode is the whole story: copytruncate keeps it and strands the descriptor's offset, while create replaces it and lets WatchedFileHandler follow.

Synchronous rotation blocking the request thread Error signature: p99 latency spikes by tens of milliseconds at a regular interval that matches the rollover cadence, with no matching change in downstream dependencies. Root cause: doRollover performs backupCount renames plus a reopen inline on the calling thread. Remediation: front the rotating handler with a QueueHandler and let a background QueueListener absorb the cost, exactly as in Step 3.

Assuming os.rename is atomic on network filesystems Error signature: duplicated or zero-length archives such as service.log.2 appearing twice in different states, or OSError: [Errno 116] Stale file handle during rollover. Root cause: rollover relies on atomic renames, and NFS or EFS mounts do not guarantee that under concurrent access. Remediation: keep logs on a local volume, or write locally and ship from there with an agent.

Leaving backupCount at its default of zero Error signature: the incident timeline you need is missing — the file contains only minutes of history, or conversely the disk fills weeks after a clean deploy. Root cause: with backupCount=0 the size handler keeps no archives at all and truncates on roll, while an unset backupCount on a timed handler retains every archive forever. Remediation: derive a finite value from the retention math above so the storage ceiling is a decision rather than an accident.

Frequently Asked Questions

How do I prevent log loss during rotation in multi-process Python applications?

Use a handler that takes an exclusive fcntl advisory lock around writes and rollover, and never use copytruncate with an external rotator. Each worker must reopen its file descriptor after rotation, either programmatically or on SIGHUP.

Should I use Python's built-in rotation or rely on OS-level logrotate?

For containerized or ephemeral environments, write JSON to stdout and let the platform handle it, or use Python's RotatingFileHandler with explicit size limits. For long-lived VM and bare-metal deployments, OS logrotate with a postrotate signal is preferred for centralized management.

How can I verify rotation integrity without impacting production performance?

Enable internal logging diagnostics, monitor that backupCount files are created in order, and track open file descriptors. Run a synthetic load test that forces several rollovers and measure the rollover latency before production rollout.

What is WatchedFileHandler and when should I use it instead of RotatingFileHandler?

WatchedFileHandler does not rotate itself; it watches the file's inode and device and reopens the file when an external tool like logrotate moves it. Use it on VMs where the OS rotator owns rotation, and use RotatingFileHandler when Python should own the size or time trigger directly.