Two ideas, neither of them obvious the first time. Read what divmod is being used for here — it is wrapping, not dividing.
from calendar import monthrange # 1. Flatten (year, month) into a single count of months since year 0. year, month = 2026, 1 index = year * 12 + (month - 1) # month - 1 so January is 0 year, month = divmod(index + 13, 12) # add 13 months and unflatten month += 1 # back to 1-12 print(year, month) # -> 2027 2 # 2. Ask the calendar how long any month is. print(monthrange(2026, 2)[1]) # -> 28 print(monthrange(2024, 2)[1]) # -> 29 (leap year, handled for you)
- Turn two numbers into one, do the maths, turn them back
Years and months carried separately mean carrying and borrowing by hand, and that is where the December-to-January bugs live. Collapsed into a single count of months, adding is just adding — divmod then splits it back into a year and a month with the wrapping already done. The same trick works for any mixed base: hours and minutes, feet and inches.
- The minus one and the plus one are not decoration
Months are 1-12 but the arithmetic wants 0-11, so you subtract one going in and add one coming out. Skip either and everything is off by a month in a way that looks right for eleven months of the year. This is the same 1-based versus 0-based boundary as the pagination exercise.
- Never hard-code month lengths
calendar.monthrange(year, month) returns (weekday of the 1st, number of days), so [1] is the length — leap years included, no table to maintain and no rule about years divisible by 400 to remember wrongly.
Do not try to add to the month number directly — the carrying is what goes wrong. Is there a way to turn a year and a month into a single number, add to that, and split it back?
index = year * 12 + (month - 1) collapses the two into one. Add months to it, then divmod(index, 12) gives back the year and the zero-based month. Once you have the target year and month, the only question left is the day.
Parse with date.fromisoformat. Flatten, add, unflatten with divmod, and add one back to the month. Then take min of the original day and calendar.monthrange(year, month)[1] — that single min is the whole clamping rule. Build a date from the three parts and return its .isoformat().
from calendar import monthrange from datetime import date def add_months(iso_date, months): start = date.fromisoformat(iso_date) index = start.year * 12 + (start.month - ____) + months year, month = divmod(index, ____) month += ____ day = ____(start.day, monthrange(year, month)[1]) return date(year, month, day).isoformat()
from calendar import monthrange
from datetime import date
def add_months(iso_date, months):
start = date.fromisoformat(iso_date)
index = start.year * 12 + (start.month - 1) + months
year, month = divmod(index, 12)
month += 1
day = min(start.day, monthrange(year, month)[1])
return date(year, month, day).isoformat()
Python’s divmod floors towards negative infinity, so divmod(-1, 12) is (-1, 11) — December of the previous year, which is exactly right. In languages that truncate towards zero this same code is wrong for every backwards jump, and people write a separate branch for it. Knowing which way your language rounds is not trivia; here it removes a whole branch.
Not an if, not a try, not a check for which months have 31 days — one min between the day you wanted and the day the month can offer. Every clamping case in the set is covered by it, in both directions. When a rule collapses to one expression, that is usually a sign you have found the real shape of it.
January 31st plus one month could equally be March 3rd — genuinely one month later by counting days. Clamping to February 28th is what billing systems do, because customers expect one charge per calendar month and not to skip February. It is a business decision wearing a date-arithmetic costume, and it belongs in the docstring so the next reader does not 'fix' it.
If dateutil is available, relativedelta(months=1) does this and a great deal more, and it is better tested than anything you will write in an afternoon. Write your own when you cannot take the dependency or you need a different clamping policy. Note too that this operates on dates, not datetimes: adding months to a timestamp raises questions about whether the time of day survives a daylight-saving boundary, and the honest answer is to convert to UTC, do the arithmetic, and convert back.
- Add an anchor_to_end option so a subscription started on the 30th of April bills on the 31st of May, and decide which behaviour a customer would call fair.
- Write add_years on top of this in one line, then check what it does to the 29th of February.
- Generate a full year of billing dates from a start date and notice whether clamping is sticky — does a clamped February date stay on the 28th forever, or return to the 31st?