Gustav
Gustav

Reputation: 15

Unexpected behavior from pythons relativedelta and datetime libraries

I'm trying to calculate the date one year from some date, in iterations of 3 months using python's dateutil.relativedelta.relativedelta and datetime.date function/modules.

I expect that adding 12 months to a year once and adding 3 months to a year 4 times would yield the same result. But it doesn't. What am I missing?

Here is a code snippet for replication:

import datetime as dt
import dateutil.relativedelta as rd

print(dt.date(2020, 7, 31) + rd.relativedelta(months=+3)
                           + rd.relativedelta(months=+3)
                           + rd.relativedelta(months=+3)
                           + rd.relativedelta(months=+3))

print(dt.date(2020, 7, 31) + rd.relativedelta(months=+12))

output:

2021-07-30
2021-07-31

I expect output:

2021-07-31
2021-07-31

Upvotes: 1

Views: 170

Answers (1)

metatoaster
metatoaster

Reputation: 18898

April 31st is not a valid date in the Gregorian calendar system. The dates being added will result in that non-existent day. With the usual intuition1 where a month from the last day of a given month should land on a day on the last day of the next month (despite it being potentially less than the usual 31 or 30 days), so this would result in April 30, and a month from that day would be May 30, not necessarily May 31st (three months after that would be July 30, following that usual intuition).

The following snippet is based on what you had, adjusted for readability, to demonstrate the issue at hand.

>>> start = dt.date(2020, 7, 31)
>>> forward_3mnth = rd.relativedelta(months=3)
>>> january = start + forward_3mnth + forward_3mnth
>>> print(january)
2021-01-31
>>> april = january + forward_3mnth
>>> print(april)
2021-04-30
>>> july = april + forward_3mnth
>>> print(july)
2021-07-30

This relates directly to issue 267 (and subsequently issue 1160) on their GitHub's project tracker.

Alternatively, group the relativedeltas together via parentheses and apply the addition onto the date (which is then equivalent to adding relativedelta(months=12)).

>>> print(start + (forward_3mnth + forward_3mnth + forward_3mnth + forward_3mnth))
2021-07-31

1 Using a relativedelta to go backwards in time will have its own related issue; given this particular pinning, the intuition to get the last day of the last month starting from the last day of a given month may give a "surprising" result.

>>> print(dt.date(2020, 2, 29) - rd.relativedelta(months=1))
2020-01-29
>>> print(dt.date(2020, 3, 1) - rd.relativedelta(months=1))
2020-02-01

One may argue (deleted question) that the above demonstrated that January 30th and 31st are skipped, but looking deeper, given that this particular Feburary has 29 days (a day more than the usual), it's impossible to subtract (or add) exactly a month over every single day in February that would result in a 1-to-1 mapping that encompasses all days of the adjacent month - January/March will have days that this algorithm will not be included, and going from January/March back into February will have duplicates. The authors of dateutil decided that the usual intuition of counting from the start (i.e. from the first day of each month) and not let this ambiguity of using months bother them too much (given the lack of response to that issue).

Lastly, I called this the "usual intuition" because if one were to pin the negative relativedelta to the last day of the month, i.e. where subtracting a month from 2020-02-29 producing 2020-01-31 being the answer, it may following that substract month from 2020-02-28, a date one day earlier, might produce 2020-01-30? Or should it be 2020-01-28? What about 2020-02-01, it follows that it could result in 2020-01-03 or a more sensible 2020-01-01?

Okay, so if only the last day of the month being this awkward special case, then why not actually use a special case handling for last day of the month, or apply a delta on the raw day number (i.e. one of 28, 29, 30, 31, or whatever else last day of the month for special case months that may exist in the past or future), and go from there? This ultimately depends on you, the reader of this long winded end note, on what you think should happen, and then write your own code based on what you think, because there is no correct answer.

Upvotes: 1

Related Questions