skip to content

Why does datetime.timedelta accept no months or years argument, and how do you shift a date by one month?

level: middleimportance: should knowfreq 35%

answer

  1. Two different meanings of 'a month later'
  2. The type stores a fixed microsecond count
  3. Calendar units have no fixed length
  4. Rebuild the date instead of adding
  5. Ask the calendar module for the month length

basics

~20 s

timedelta models an exact elapsed duration, and months and years have no fixed length - 28 to 31 days, 365 or 366. Calendar shifts are done by rebuilding the date with replace(), clamping the day using calendar.monthrange().

solid answer

~50 s

A `timedelta` stores an exact duration as days, seconds and microseconds, and every constructor keyword - `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds`, `microseconds` - converts into a fixed number of microseconds. Months and years cannot: a month is 28 to 31 days and a year is 365 or 366, so there is no number to store and the type refuses the question rather than guessing. That is why `timedelta(days=30)` is wrong for a monthly renewal - it drifts relative to the calendar every cycle - and `timedelta(days=365)` breaks on a leap year. For calendar arithmetic you rebuild the date: compute the target year and month with `divmod`-style arithmetic on total months, ask `calendar.monthrange(year, month)[1]` for that month's length, clamp the day, and call `date.replace()`. Clamping is a policy decision - 31 January plus one month is 28 February only because you chose that.

code

python · 15 lines
python
from calendar import monthrange
from datetime import date


def add_months(d, n):
    total = d.month - 1 + n
    year = d.year + total // 12
    month = total % 12 + 1
    day = min(d.day, monthrange(year, month)[1])
    return d.replace(year=year, month=month, day=day)


print(add_months(date(2026, 1, 31), 1))
print(add_months(date(2026, 3, 31), -1))
print(add_months(date(2026, 12, 15), 3))

go deeper

for a junior

Recall that timedelta takes weeks, days, hours, minutes, seconds, milliseconds and microseconds - and not months or years. Know that days=30 is not a month.

for a middle

Explain why: every accepted keyword converts to a fixed microsecond count, and months and years do not have one. Show the calendar.monthrange plus date.replace shift and handle the month-end case.

for a senior

Make the clamping policy explicit and test it - month-end into a shorter month, 29 February, negative shifts, year boundaries. Point out that repeated clamping is sticky and that billing shifts should be computed from the original anchor date.

for a principal

Decide once, for the whole system, what a monthly period means - anchor day, clamping rule, and behaviour at month end - and make it one shared, documented helper. Divergent local answers to that question are what produce cross-service billing and expiry disputes.

## Two different kinds of "time later" There are two operations people call "add a month", and they are not the same: - **Duration arithmetic**: advance an instant by an exact amount of elapsed time. Physical, unambiguous, and what `timedelta` models. - **Calendar arithmetic**: land on the same day-of-month in a later month. Symbolic, depends on the calendar, and sometimes has no answer at all. `datetime.timedelta` implements only the first. Its constructor accepts `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds` and `microseconds`, and every one of those converts to a fixed count of microseconds. It stores the result normalised into three integers - `days`, `seconds`, `microseconds`. Ask for months and there is no honest number to store: is it 28 days, 30, or 31? So the type declines. That refusal is a design decision, and interviewers ask about it because the alternative - guessing 30 - is a bug people ship constantly. ## The bugs the refusal prevents `timedelta(days=30)` as "one month" drifts. A subscription renewed that way from 31 January lands on 2 March, then 1 April, then 1 May - twelve renewals cover 360 days, so by December the billing date has walked back nearly a week and the customer gets thirteen charges in a year. `timedelta(days=365)` as "one year" is right three times in four and silently off by a day after a leap year, which is exactly the kind of defect that reaches production because the test suite was written in a non-leap year. ## Doing it with the standard library There is no `add_months` in the standard library, but the pieces are there. `calendar.monthrange(year, month)` returns a two-tuple `(weekday_of_the_first, number_of_days_in_the_month)`; the second element is the length you need. Combine it with `date.replace()`: ```python from calendar import monthrange from datetime import date def add_months(d, n): total = d.month - 1 + n year = d.year + total // 12 month = total % 12 + 1 day = min(d.day, monthrange(year, month)[1]) return d.replace(year=year, month=month, day=day) add_months(date(2026, 1, 31), 1) # date(2026, 2, 28) add_months(date(2026, 3, 31), -1) # date(2026, 2, 28) ``` Converting the month to a zero-based total before dividing is what makes negative `n` work without a special case, because Python's floor division and modulo agree on negatives. ## The clamp is a policy, not a fact Notice that `add_months` above **clamps**: 31 January plus one month is 28 February. That is one defensible answer among several. Another is to roll over into 3 March. A third is to refuse and raise, forcing the caller to decide. A fourth - common in billing - is to remember the *original* anchor day and clamp only for display, so that 31 January, 28 February, 31 March stays anchored to 31 rather than collapsing to 28 permanently. The naive clamp is *sticky*: apply it month by month and 31 January becomes 28 February and then 28 March, quietly losing three days of the anchor forever. Applying `add_months(anchor, n)` from the original anchor each time avoids that. None of these is "correct"; the failure mode is not choosing. Write the rule down in the function's docstring and test the four interesting inputs: month-end into a shorter month, 29 February into a non-leap year, a negative shift, and a shift that crosses a year boundary. ## Related details worth knowing - `date.replace()` and `datetime.replace()` return a **new** object with the given fields substituted and everything else copied. They validate: `date(2026, 1, 31).replace(month=2)` raises `ValueError`, which is why the clamp has to happen before the call. - `calendar.isleap(year)` answers the leap question directly if that is all you need. - `timedelta` constructor arguments may be floats; they are summed and rounded to the nearest microsecond, ties going to even. `timedelta(seconds=0.5, milliseconds=0.5)` is exactly `0:00:00.500500`, and sub-microsecond input simply disappears. - `timedelta(weeks=2)` is exact and equals `timedelta(days=14)`, because a week genuinely is a fixed number of days - which is why `weeks` is accepted and `months` is not. - Adding a `timedelta` to an aware `datetime` operates on the local clock fields; what that means across a daylight-saving transition is a separate topic with its own rules. - Third-party calendar-arithmetic libraries provide a "relative delta" type with months and years, and they exist precisely because this is a policy question the standard library declines to answer for you. Pulling one in is reasonable; pulling one in without knowing what its month-end rule is, is not. ## What the interviewer is listening for The distinction between elapsed duration and calendar position, stated in one sentence. Then the concrete consequence - why `days=30` drifts and `days=365` breaks on leap years - and finally the fact that month-end clamping is a decision your code makes, not something the library can hand you.

  • What should 31 January plus one month be, and who decides?
    Your code decides. Clamping gives 28 February, rolling over gives 3 March, and raising forces the caller to choose. Billing systems usually clamp for display while keeping the original anchor day, so the sequence stays 31, 28, 31 rather than collapsing permanently to 28. The defect is not picking the wrong rule, it is leaving the rule undocumented and untested.
  • What happens if you pass float arguments to timedelta?
    They are accepted, summed and rounded to the nearest microsecond, with ties resolved to even. `timedelta(seconds=0.5, milliseconds=0.5)` is exactly 500500 microseconds, and anything below the microsecond resolution vanishes. That makes float arguments fine for readability but unsuitable for accumulating many small values, where the rounding compounds - keep an integer count of microseconds instead.
  • Why is weeks an accepted keyword when months is not?
    A week is exactly seven days by definition, everywhere, so `timedelta(weeks=2)` converts to a fixed microsecond count and equals `timedelta(days=14)`. A month has no such definition - it is 28 to 31 days depending on which month and which year - so there is no value to store. The constructor accepts precisely the units that are fixed-length.

A ruler measures distance and a street map places addresses; asking a ruler for 'one block over' is the same category error as asking a duration type for one month.

saying these in an interview costs you the question

  • Uses timedelta(days=30) as one month
  • Uses timedelta(days=365) as one year
  • Thinks timedelta stores the units you passed in
  • Expects the standard library to define month-end behaviour
  • Calls date.replace(month=2) on the 31st without clamping

context