Calendar Arithmetic

Date and Time in Practice

Chapter 6 ยท Calendar Arithmetic

Chapters 3 to 5 dealt with clocks. This chapter deals with calendars, where the trouble is that months have different lengths, years have different lengths, and "working day" is a local convention rather than a fact. timedelta counts days and seconds only; it has no idea what a month is. Every snippet below was run with Python 3.14; the comments show what was printed.

Months Are Not a Fixed Length

There is no timedelta(months=1), because there is no single right answer for its length. The tempting shortcut is replace(month=...), which fails whenever the day does not exist in the target month:

import calendar from datetime import date, timedelta try: date(2026, 1, 31).replace(month=2) except ValueError as e: print(e) # day 31 must be in range 1..28 for month 2 in year 2026 print(calendar.monthrange(2026, 2)) # (calendar.SUNDAY, 28) print(calendar.monthrange(2028, 2)) # (calendar.TUESDAY, 29)

calendar.monthrange(year, month) returns the weekday of the 1st (Monday is 0, and the constants print by name) and the number of days in the month. The second value is what you need to clamp a day number safely:

def add_months(d, n): year, month = divmod(d.year * 12 + d.month - 1 + n, 12) month += 1 last = calendar.monthrange(year, month)[1] return date(year, month, min(d.day, last)) print(add_months(date(2026, 1, 31), 1)) # 2026-02-28 print(add_months(date(2026, 1, 31), 2)) # 2026-03-31 print(add_months(date(2026, 3, 31), -1)) # 2026-02-28 print(add_months(date(2026, 11, 30), 3)) # 2027-02-28

The trick is divmod on a single month counter (year * 12 + month - 1), which handles year rollover in both directions without special cases. Now the trap: clamping throws information away.

once = add_months(date(2026, 1, 31), 1) twice = add_months(once, 1) print(twice) # 2026-03-28 print(add_months(date(2026, 1, 31), 2)) # 2026-03-31

Adding one month twice and adding two months once disagree by three days. Once 31 January became 28 February, the 31 was gone for good. Always add the full number of months to the original date, and store the original date (for example a billing anchor of "the 31st") rather than the clamped one.

Month-end is a policy
"Add a month" has at least three defensible meanings: clamp to the last day (used above), roll over into the next month (31 January plus one month becomes 3 March), or refuse. Billing systems usually clamp; some contracts roll over. Pick one, name it in the function, and test the 29th, 30th and 31st.

Leap Years

A year is a leap year if it is divisible by 4, except years divisible by 100, unless they are also divisible by 400. So 2000 and 2028 are leap years; 1900 and 2026 are not. Do not hand-roll this rule when the standard library has it:

print(calendar.isleap(1900), calendar.isleap(2000)) # False True print(calendar.isleap(2028), calendar.isleap(2026)) # True False print(calendar.leapdays(2000, 2030)) # 8 def next_birthday(born, today): for y in (today.year, today.year + 1): last = calendar.monthrange(y, born.month)[1] cand = date(y, born.month, min(born.day, last)) if cand >= today: return cand print(next_birthday(date(2024, 2, 29), date(2026, 3, 1))) # 2027-02-28 print(next_birthday(date(2024, 2, 29), date(2027, 3, 1))) # 2028-02-29

calendar.leapdays(y1, y2) counts leap years from y1 up to but not including y2. The same clamping idea from add_months handles anniversaries of 29 February. Note also that a year has 365 or 366 days, so "add 365 days" is not "add a year": from 1 January 2028 it gives 31 December 2028, one day short of 1 January 2029.

The calendar Module

Weekdays come from the date itself; names and grids come from calendar:

d = date(2026, 11, 2) print(d.weekday(), d.isoweekday()) # 0 1 print(calendar.day_name[d.weekday()]) # Monday print(calendar.month_name[11], calendar.month_abbr[11]) # November Nov print(calendar.monthcalendar(2026, 11)[0]) # [0, 0, 0, 0, 0, 0, 1] print(calendar.monthcalendar(2026, 11)[-1]) # [30, 0, 0, 0, 0, 0, 0]

weekday() counts Monday as 0; isoweekday() counts Monday as 1 and Sunday as 7. Mixing the two is a classic off-by-one. monthcalendar returns one list per week, Monday first by default, with 0 padding days that belong to neighbouring months. Be aware that calendar.day_name and month_name follow the current locale, which Chapter 2 warned about.

The nth Weekday of a Month

Rules such as "the fourth Thursday of November" or "the last Monday of May" appear in holiday and meeting rules. Compute them rather than looking them up:

def nth_weekday(year, month, weekday, n): first = date(year, month, 1) offset = (weekday - first.weekday()) % 7 day = 1 + offset + 7 * (n - 1) if day > calendar.monthrange(year, month)[1]: raise ValueError("no such date") return date(year, month, day) def last_weekday(year, month, weekday): last = date(year, month, calendar.monthrange(year, month)[1]) return last - timedelta(days=(last.weekday() - weekday) % 7) print(nth_weekday(2026, 11, 3, 4)) # 2026-11-26 (4th Thursday) print(nth_weekday(2026, 5, 0, 1)) # 2026-05-04 (1st Monday) print(last_weekday(2026, 5, 0)) # 2026-05-25 (last Monday) try: nth_weekday(2026, 2, 0, 5) except ValueError as e: print(e) # no such date

The offset line finds how many days to move forward from the 1st to reach the wanted weekday; the modulo keeps it between 0 and 6. A fifth occurrence may not exist, so the function says so instead of quietly spilling into the next month.

Business Days

A business day is a weekday that is not a holiday, and the holiday list belongs to a country, a company or a contract. There is no universal answer, so the list is data you pass in. The simple, correct version loops over the days:

def business_days(start, end, holidays=frozenset()): """Business days in [start, end): end is not counted.""" n, d = 0, start while d < end: if d.weekday() < 5 and d not in holidays: n += 1 d += timedelta(days=1) return n def add_business_days(d, n, holidays=frozenset()): step = 1 if n >= 0 else -1 left = abs(n) while left: d += timedelta(days=step) if d.weekday() < 5 and d not in holidays: left -= 1 return d hol = {date(2026, 11, 26)} print(business_days(date(2026, 11, 2), date(2026, 11, 30))) # 20 print(business_days(date(2026, 11, 2), date(2026, 11, 30), hol)) # 19 print(add_business_days(date(2026, 11, 26), 3, hol)) # 2026-12-01 print(add_business_days(date(2026, 11, 25), 1, hol)) # 2026-11-27 print(add_business_days(date(2026, 11, 27), 0)) # 2026-11-27

Looping is fast enough for ranges of months or years and is easy to trust. Edge cases to decide up front: whether the end date counts (here it does not, like Python's range), what adding zero business days does when the start is a weekend (here: returns it unchanged), and whether a holiday that falls on a weekend is observed on the next Monday (that is an entry in the holiday list, not logic in the function).

QuestionToolWatch for
Days in a monthcalendar.monthrange(y, m)[1]February in leap years
Is it a leap yearcalendar.isleap(y)1900 and 2100 are not
Add n monthsyour own add_monthsclamp from the original date, not a clamped one
Weekday numberweekday() or isoweekday()0-based versus 1-based
nth weekday of a monthyour own nth_weekdaya fifth one may not exist
Working days betweenloop with a holiday setholidays are data, and the end date is excluded

Challenges

Exercise 1

Write add_months with clamping. Starting from 31 January 2026, print the result of adding 0 to 4 months directly, next to the result of adding one month four times in a row, and mark each row where the two differ. Predict which rows are marked before you run it.

๐Ÿ“„ View solution
Exercise 2

Someone was born on 29 February 2024. Print their birthday in each year from 2025 to 2029 (using 28 February in non-leap years) with the weekday and whether the year is a leap year. Then list the leap years from 1896 to 1912 and count the leap years from 1900 to 2100 inclusive. Predict the count first.

๐Ÿ“„ View solution
Exercise 3

With holidays on 25 and 28 December 2026, write count_business(start, end) and add_business_days(d, n). Print the business days in December 2026, the date 10 business days after 15 December, the date 1 business day after 24 December, the result of adding 0 business days to Saturday 26 December, and the count for an empty range. Predict the December count by hand first.

๐Ÿ“„ View solution

Quick Reference

  • No month timedelta: write add_months using divmod on year * 12 + month - 1
  • Clamp the day with min(day, calendar.monthrange(y, m)[1])
  • Never chain clamped results: add the full number of months to the original date
  • Leap year: calendar.isleap(y); divisible by 4, except centuries not divisible by 400
  • Weekdays: weekday() Monday is 0; isoweekday() Monday is 1
  • nth weekday: offset to the first match, plus 7 per extra week, check it still fits in the month
  • Business days: weekday and not in a holiday set; decide whether the end date counts
Coming next
Date and Time in Practice 7 looks at durations and intervals: the limits of timedelta, detecting overlaps, generating ranges and bucketing events into hours and days.