Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions docs/misc/prune-example-interval.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Calendar view
8 9 10 11 12 13 14
15y16 15 16 17 18 19 20 21
17 18 19 20 21 22 23 22 23 24 25 26 27 28
24 25 26 27 28 29 30 29 30 31m
24 25 26 27 28 29 30 29 30 31y

2026
January February March
Expand All @@ -83,12 +83,13 @@ List view

--keep-daily 1w --keep-weekly 4 --keep-monthly 5m --keep-yearly 2
--------------------------------------------------------------------------------
1. 2025-11-15 (oldest)
1. 2025-12-31
2. 2025-11-15 (oldest)
1. 2026-06-02 1. 2026-05-23 1. 2026-04-30
2. 2026-06-01 2. 2026-05-17 2. 2026-03-30
3. 2026-05-31 3. 2026-05-10 3. 2026-02-28
4. 2026-05-30 4. 2026-05-03 4. 2026-01-31
5. 2026-05-29 5. 2025-12-31
5. 2026-05-29
6. 2026-05-28

2026-06-04 is additionally kept due to `--from`.
Expand Down Expand Up @@ -132,14 +133,12 @@ Since 2026-05-24 and 2026-03-31 were skipped, Borg substitutes the next best
candidate for each: 2026-05-23 fills the week 21 weekly slot, and 2026-03-30
fills the March monthly slot.

The implementation of `--keep-monthly 5m` is somewhat special: Borg defines a
month as a fixed 31-day period, independent of the actual calendar dates
involved. As a result, `5m` corresponds to 5 × 31 = 155 days. The archive from
2025-12-31 16:00 is exactly 155 days older than the reference time and is
therefore retained by `--keep-monthly`.
The `--keep-monthly 5m` window starts five calendar months before the
reference time, on 2026-01-04 at 16:00. The archive from 2025-12-31 is
outside this window, so it is not retained by `--keep-monthly`.

As a result, there are no true yearly candidates. In the absence of a better
candidate, `--keep-yearly 2` only matches the oldest archive, 2025-11-15.
Instead, `--keep-yearly 2` keeps 2025-12-31 as its yearly candidate. It
also keeps the oldest archive, 2025-11-15, to fill the remaining slot.

Since interval rules define time windows rather than competing for a fixed
number of slots, their interplay is simpler than count-based rules. An archive
Expand All @@ -150,6 +149,5 @@ Intervals and counts can be mixed freely. Yearly retention in this example is
done by retention count instead of intervals. A count rule paired with interval
rules behaves just as it would if all preceding rules were also counts:
Archives already kept by earlier rules are excluded from consideration. In this
example, every yearly slot is consumed by earlier rules; no true yearly
candidate remains, so the oldest archive at 2025-11-15 is kept to preserve
rolling backup semantics.
example, the 2026 yearly slot is consumed by earlier rules. December 31
fills the 2025 slot, and November 15 is retained as the oldest archive.
Comment on lines +152 to +153

@PhrozenByte PhrozenByte Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
example, the 2026 yearly slot is consumed by earlier rules. December 31
fills the 2025 slot, and November 15 is retained as the oldest archive.
example, 2025-12-31 fills the first slot, and 2025-11-15 fills the second slot
as the oldest remaining archive, preserving rolling backup semantics.
  1. "YYYY slot" suggests an interval-based rule, so it's better to refer to the slots by number ("first slot").
  2. The absence of a yearly candidate for 2026 isn't due to earlier rules, but to 2026 being the current year.
  3. Use the YYYY-MM-DD date format for consistency.

However, more importantly, the original point of this paragraph has been lost. It was meant to illustrate that --keep-yearly 2 doesn't count 2025-12-31, because that archive was already kept by the previous --keep-monthly rule. With calendar-aware retention intervals, --keep-monthly 5m no longer matches 2025-12-31.

We could change the example to --keep-monthly 6m instead. That way, 2025-12-31 is matched by --keep-monthly again, leaving no "true" candidates for --keep-yearly.

Alternatively, this paragraph might simply be removed and/or merged with the --keep-weekly paragraphs. It's somewhat redundant, because we mix count-based and interval-based rules there already anyway. Just a short sentence about --keep-yearly keeping 2025-11-15 as the oldest archive to preserve rolling backup semantics should remain.

16 changes: 12 additions & 4 deletions docs/usage/general/date-time.rst.inc
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ The ``borg prune`` ``--keep-*`` retention options accept either a plain count
When using interval-based retention, ``--from`` may be specified to set the
reference timestamp for the interval (defaults to the current time).

Please note that Borg treats months (e.g. ``12m``) as fixed 31-day periods
rather than calendar months. As a result, ``12m`` corresponds to
12 × 31 = 372 days. Similarly, years (e.g. ``2y``) are treated as fixed
365-day periods and do not take leap years into account.
Months and years are calendar-aware. For example, ``12m`` before
2026-07-22 is 2025-07-22, and ``3y`` before 2026-07-22 is 2023-07-22.
The time of day is preserved. If the target month has fewer days, the date
is clamped to its last day: one month before March 31 is February 28
(or February 29 in a leap year).
Comment on lines +29 to +30

@PhrozenByte PhrozenByte Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
is clamped to its last day: one month before March 31 is February 28
(or February 29 in a leap year).
is clamped to its last day: one month before 2024-03-31 is 2024-02-29 (due
to 2024 being a leap year), and one year before 2024-02-29 is 2023-02-28.

Use the YYYY-MM-DD date format for consistency. 2024 could be used to illustrate a leap year.


Weeks, days, hours, minutes, and seconds keep their fixed-length meanings.
For example, ``1w`` is seven days. Plain counts and ``all`` are unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Plain counts and all are unchanged." This reads like a description of how to current implementation differs from an earlier one, but the docs should just document the current version.


When combining interval-based pruning rules, their lengths are compared at
the same reference timestamp. Thus, ``1m`` can be shorter than, equal to,
or longer than ``30d`` depending on the month.
Comment on lines +32 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Weeks, days, hours, minutes, and seconds keep their fixed-length meanings.
For example, ``1w`` is seven days. Plain counts and ``all`` are unchanged.
When combining interval-based pruning rules, their lengths are compared at
the same reference timestamp. Thus, ``1m`` can be shorter than, equal to,
or longer than ``30d`` depending on the month.
Weeks, days, hours, minutes, and seconds have a fixed length. For example,
``1w`` is 7 days, and ``1d`` matches ``86400s``. Years and months are
interpreted relative to a reference timestamp and can vary in length.
Thus, ``1m`` can be shorter than, equal to, or longer than 30 days,
depending on the month. ``1y`` can match either ``365d`` or ``366d``,
depending on the year, but it always matches 12 months.

As Dan noted earlier, the first paragraph reads more like a changelog entry. I think we can merge these two paragraphs and mention years here as well.

Furthermore, timespans aren't only used by prune, so we shouldn't limit this explanation to that command. It might make sense to move the paragraph starting with "The borg prune --keep-* retention options […]" below this one, making it the last paragraph of this section.

24 changes: 18 additions & 6 deletions src/borg/archiver/prune_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..helpers import GroupBySpec
from ..helpers import json_print, basic_json_data
from ..helpers.argparsing import ArgumentParser
from ..helpers.time import calculate_relative_offset
from ..manifest import AI_GROUP_BY_KEYS, ArchiveInfo, format_group_key, group_archives

from ..logger import create_logger
Expand Down Expand Up @@ -198,6 +199,18 @@ class PruneMixIn:
@with_repository()
def do_prune(self, args, repository, manifest):
"""Prune archives according to specified rules."""
from_timestamp = getattr(args, PRUNE_FROM.key)
base_timestamp = from_timestamp if from_timestamp is not None else datetime.now().astimezone()

for rule in PRUNING_RULES:
value = getattr(args, rule.key)
if not isinstance(value, str):
continue
try:
cutoff = calculate_relative_offset(value, base_timestamp, earlier=True)
except (ValueError, OverflowError) as error:
raise CommandError(f"Invalid retention interval {value!r} for {rule.key}: {error}") from None
setattr(args, rule.key, base_timestamp - cutoff)
self._validate_prune_args(args)

match = archive_match_patterns(args)
Expand All @@ -210,10 +223,6 @@ def do_prune(self, args, repository, manifest):
group_by = tuple(args.group_by.split(",")) if args.group_by else ()
groups = group_archives(archives, group_by)

# All groups use the same reference timestamp, so interval based rules do not drift apart.
from_timestamp = getattr(args, PRUNE_FROM.key)
base_timestamp = from_timestamp if from_timestamp is not None else datetime.now().astimezone()

# Archives to keep along with the rule that ensured them being kept
keep = {}
group_of = {}
Expand Down Expand Up @@ -465,7 +474,8 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
that time window. When ``--from`` is given together with an interval
retention, the interval is measured backwards from that timestamp
instead of from the current time. See ``Date and Time`` docs for exact
INTERVAL format.
INTERVAL format. Months and years are calendar-aware: a month before
March 31 is February 28 (or 29 in a leap year), at the same time of day.

The ``--keep-secondly``, ``--keep-minutely``, ``--keep-hourly``,
``--keep-daily``, ``--keep-weekly``, ``--keep-monthly``,
Expand Down Expand Up @@ -505,7 +515,9 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
weekly interval is already covered by the daily one and so the weekly
interval is effectively useless. An error is emitted upon running
``borg prune`` if such a combination of flags is given. The order of
flags on the command line is not significant.
flags on the command line is not significant. Calendar intervals are
compared at the reference timestamp: ``1m`` can be shorter than, equal
to, or longer than ``30d`` depending on the month.


A practical approach for recurring backups is to use rules
Expand Down
2 changes: 1 addition & 1 deletion src/borg/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper
from .parseformat import octal_int, bin_to_hex, hex_to_bin, safe_encode, safe_decode
from .parseformat import text_to_json, binary_to_json, remove_surrogates, join_cmd
from .parseformat import eval_escapes, decode_dict, interval, int_or_interval, nonnegative_seconds
from .parseformat import eval_escapes, decode_dict, int_or_interval, nonnegative_seconds
from .parseformat import (
PathSpec,
FilesystemPathSpec,
Expand Down
51 changes: 5 additions & 46 deletions src/borg/helpers/parseformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import uuid
from pathlib import Path
from typing import ClassVar, Any, TYPE_CHECKING, Literal
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from functools import lru_cache, partial
from hashlib import sha256
from string import Formatter
Expand Down Expand Up @@ -141,58 +141,17 @@ def nonnegative_seconds(s):
return seconds


def interval(s):
"""Parse an interval string (e.g. ``7d``, ``2w``, ``30M``) into a timedelta.

Supported units: y (years, 365d), m (months, 31d), w (weeks), d (days),
H (hours), M (minutes), S (seconds). The value must be nonnegative.
"""
if isinstance(s, timedelta):
return s

seconds_in_a_minute = 60
seconds_in_an_hour = 60 * seconds_in_a_minute
seconds_in_a_day = 24 * seconds_in_an_hour
seconds_in_a_week = 7 * seconds_in_a_day
seconds_in_a_month = 31 * seconds_in_a_day
seconds_in_a_year = 365 * seconds_in_a_day
multiplier = dict(
y=seconds_in_a_year,
m=seconds_in_a_month,
w=seconds_in_a_week,
d=seconds_in_a_day,
H=seconds_in_an_hour,
M=seconds_in_a_minute,
S=1,
)

if s.endswith(tuple(multiplier.keys())):
number = s[:-1]
suffix = s[-1]
else:
raise ArgumentTypeError(f'Unexpected time unit "{s[-1]}": choose from {", ".join(multiplier)}')

try:
seconds = int(number) * multiplier[suffix]
except ValueError:
seconds = -1

if seconds < 0:
raise ArgumentTypeError(f'Invalid number "{number}": expected nonnegative integer')

return timedelta(seconds=seconds)


def int_or_interval(s):
"""Parse *s* as an :class:`int` or, failing that, as an interval string.

Returns :class:`int` if *s* can be parsed as an integer (e.g. ``"7"``),
or :class:`datetime.timedelta` if *s* is a valid interval (e.g. ``"7d"``).
or the validated interval string (e.g. ``"7d"``). Months and years need a
reference timestamp before they can be converted to a duration.

Raises :class:`ArgumentTypeError` if *s* is neither an integer nor
a valid interval.
"""
if isinstance(s, (int, timedelta)):
if isinstance(s, int):
return s

# Explicitly check 'all' as a shortcut to 'infinite' sentinel value `-1`.
Expand All @@ -205,7 +164,7 @@ def int_or_interval(s):
pass

try:
return interval(s)
return relative_time_marker_validator(s)
except ArgumentTypeError as e:
raise ArgumentTypeError(f"Value is neither an integer nor an interval: {e}")

Expand Down
Loading
Loading