#479·boltons

fileutils.rotate_file(keep=1) deletes the file it just rotated; keep=N retains N-1 generations

Author: ArtJackCreated Sep 7, 2026Updated Sep 7, 2026

rotate_file removes the oldest generation after the rename ladder has already moved every file up by one, so the generation it just wrote is the one it deletes. With keep=1 the file is destroyed and no backup exists; with any keep=N, N-1 generations survive.

Reproduction (fresh clone at 967864f, Python 3.13 and 3.14, everything in a tempdir):

python
import os, tempfile
from boltons.fileutils import rotate_file

with tempfile.TemporaryDirectory() as d:
    p = os.path.join(d, "f.txt")
    open(p, "w").write("CRITICAL DATA")
    rotate_file(p, keep=1)
    print(sorted(os.listdir(d)))      # []  — the file is gone, nothing was kept

with tempfile.TemporaryDirectory() as d:
    p = os.path.join(d, "f.txt")
    for i in range(1, 6):
        open(os.path.join(d, f"f.{i}.txt"), "w").write(f"gen {i}")
    open(p, "w").write("current")
    rotate_file(p, keep=5)
    print(sorted(os.listdir(d)))      # ['f.1.txt', 'f.2.txt', 'f.3.txt', 'f.4.txt'] — five requested, four kept

Mechanismboltons/fileutils.py, rotate_file:

python
    fns = [filename] + kept_names
    for orig_name, kept_name in reversed(list(zip(fns, fns[1:]))):   # lines 720-723: f.4 → f.5, …, f → f.1
        if not os.path.exists(orig_name):
            continue
        os.rename(orig_name, kept_name)

    if os.path.exists(kept_names[-1]):                                 # lines 725-726: then f.5 — just written — is removed
        os.remove(kept_names[-1])

The docstring promises "dropping any files beyond keep"; the code drops the file at keep. The removal belongs before the ladder (delete the current .keep so the ladder has room), or the ladder should stop at keep-1.

Note that tests/test_fileutils.py::test_rotate_file_full_rotation (and its _no_ext twin) currently assert not (tmp_path / 'test_file.5.txt').exists() after keep=5, i.e. they pin the off-by-one, so a fix arrives with those two tests updated. The behaviour has been this way since the function was introduced in 3bfcfdd (2024-11-28); git describe at HEAD is 26.1.0-24-g…, so it is still fixable before 26.1.1.

Found by an automated QA pass over the repository (Verdict); the reproduction above was re-run by hand on a fresh clone before filing.