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:
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:
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.
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.
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:
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:
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:
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:
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).
| Question | Tool | Watch for |
|---|---|---|
| Days in a month | calendar.monthrange(y, m)[1] | February in leap years |
| Is it a leap year | calendar.isleap(y) | 1900 and 2100 are not |
| Add n months | your own add_months | clamp from the original date, not a clamped one |
| Weekday number | weekday() or isoweekday() | 0-based versus 1-based |
| nth weekday of a month | your own nth_weekday | a fifth one may not exist |
| Working days between | loop with a holiday set | holidays are data, and the end date is excluded |
Challenges
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.
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 solutionWith 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.
Quick Reference
- No month timedelta: write
add_monthsusingdivmodonyear * 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
timedelta, detecting overlaps, generating ranges and bucketing events into hours and days.