Two things to pin down before any arithmetic: how Python numbers the days, and whether the last one is in.
from datetime import date, timedelta
date(2026, 9, 7).weekday() # -> 0 Monday is 0
date(2026, 9, 12).weekday() # -> 5 Saturday
date(2026, 9, 13).weekday() # -> 6 Sunday
# so 'is a weekend' is weekday() >= 5
# isoweekday() numbers Monday 1 to Sunday 7 — a different test entirely
# Walking a range inclusively
day = start
while day <= end: # <= , not <
day += timedelta(days=1)
- weekday() starts at Monday; isoweekday() starts at one
weekday() gives 0 to 6 with Monday at 0, so the weekend is 5 and 6. isoweekday() gives 1 to 7 with Monday at 1, so the weekend is 6 and 7. Both are reasonable and mixing them up shifts every answer by a day — check which one you called before trusting a comparison against 5.
- Inclusive means <=, and it is worth one extra case
Business-day promises count both ends: Monday to Monday is one day, not zero. That makes the loop condition <= rather than <, which is the opposite of nearly every other range you write in Python. Being deliberate about it — and testing a single-day range — is how you avoid a whole class of off-by-one.
- Holidays belong in a set
A public holiday list can be a hundred dates and the check runs once per day in the range, so a list makes it quadratic-ish and a set makes it constant. Converting once at the top also means the function accepts a list, a tuple or a generator without caring which.
Walk one day at a time from start to end, counting the ones that qualify. Two things disqualify a day, and they are independent of each other.
timedelta(days=1) is how you step. The loop condition is <= end, because both ends count. A day counts when day.weekday() < 5 and day is not in the holidays.
Turn holidays into a set once, before the loop — it makes the membership test constant and accepts whatever collection the caller had. Validate the order of the dates first, so an inverted range is an error rather than a zero.
from datetime import timedelta def business_days(start, end, holidays=()): if end < start: raise ____(f’end {end} is before start {start}') closed = ____(holidays) count = 0 day = start while day ____ end: if day.weekday() < ____ and day not in closed: count += 1 day += timedelta(days=____) return count
from datetime import timedelta
SATURDAY = 5
def business_days(start, end, holidays=()):
if end < start:
raise ValueError(f"end {end} is before start {start}")
# A set once, rather than a list scan per day of the range.
closed = set(holidays)
count = 0
day = start
# <= because both ends of a business-day promise are included.
while day <= end:
if day.weekday() < SATURDAY and day not in closed:
count += 1
day += timedelta(days=1)
return count
A day counts when it is a weekday *and* not a holiday, and keeping those separate is what makes the weekend-holiday case fall out correctly — a Saturday fails the first test and the second never matters. The version that counts weekdays and then subtracts len(holidays) conflates them and over-counts every holiday that lands at a weekend.
weekday() < 5 requires the reader to remember which of the two numbering schemes is in play. The constant says it, and it is the line somebody will come and change for a country whose weekend is Friday and Saturday — which is most of the Gulf. Naming the number is what makes that a one-line change rather than an archaeology exercise.
Stepping day by day is clear and costs nothing over a range of weeks. Over a range of decades — amortisation schedules, pension projections — the closed-form version is worth it: whole weeks times five, plus the ragged ends, minus the holidays that fall on weekdays. It is four lines of arithmetic nobody gets right first time, which is exactly why you write this version first and test the fast one against it.
Public holidays are the hard part and they are somebody else’s problem: the holidays package knows them for over a hundred countries including the regional variations and the rules for when one falls at a weekend. numpy.busday_count does the counting itself, vectorised, with a configurable week mask — which is what you want for anything Gulf-based or for a six-day working week. And watch the boundary convention: numpy’s is half-open, so it disagrees with this function by one on every range. If the deadline is contractual, find out which convention the contract means before you write any code at all.
- Add a week_mask so a Friday-Saturday weekend is a parameter rather than an assumption.
- Write add_business_days(start, n) — the other question everybody asks — and check it against this function for a few hundred ranges.
- Rewrite it as arithmetic with no loop, and use the loop version as the oracle to test it against every range in a year.