dates.business-days-between
Working days between two dates, excluding weekends and a caller-supplied holiday list.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 26 tests, run in TypeScript, Python and Rust.
What it does
The interval is half-open: start inclusive, end exclusive. Two consequences a caller has to know. Same date twice is 0, not 1. And intervals compose: between(a,b) + between(b,c) = between(a,c), which is what lets a report be summed by week or by month without double counting the boundary days. If you want the end date included - "the job takes five working days and today counts" - pass the day after it.
A start later than the end returns a negative count, exactly -between(end, start). Clamping to zero would silently absorb an argument-order bug, and a payment term calculated from swapped dates should look obviously wrong rather than plausibly zero.
For example
business_days_between(2026-09-14, 2026-09-21, )→ 5 a full Monday-to-Monday week is five working daysbusiness_days_between(2026-09-16, 2026-09-16, )→ 0 the same date twice is zero: the interval is half-openbusiness_days_between(2026-09-16, 2026-09-17, )→ 1 start is inclusive, so one working day spans one night
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def business_days_between(start_iso: str, end_iso: str, holidays: Sequence[str]) -> int
| start_iso | date | ISO date, inclusive |
| end_iso | date | ISO date, exclusive |
| holidays | date[] | non-working dates; order and duplicates do not matter |
| returns | int | working days in [start, end); negative when start is after end |
Your code names it in one line, in the file that uses it
from fune.dates.business_days_between import business_days_between # dates.business-days-between@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Sequence, Set
from .dates_add_days import epoch_day_from_iso ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week ← from dates.day-of-week ^1.0.0 · built alongside by fune
def business_days_between(start_iso: str, end_iso: str, holidays: Sequence[str] = ()) -> int:
"""Working days between two dates, excluding weekends and listed holidays.
The interval is half-open: the start date counts, the end date does not.
That is the convention that makes ranges compose -
business_days_between(a, b) + business_days_between(b, c) equals
business_days_between(a, c) - and it makes "how many working days until the
deadline" come out at zero on the deadline itself rather than one. If you
want the end date included, ask for the day after it.
A start later than the end returns a negative count, with the same
magnitude as the forward direction, so business_days_between(a, b) is
exactly -business_days_between(b, a). Returning zero or raising would both
hide a caller's argument-order bug.
Holidays are an argument, not built in, because no library knows which days
your company is closed. Weekend holidays are not double counted, duplicates
in the list are harmless, and holidays outside the interval are ignored.
"""
start = epoch_day_from_iso(start_iso)
end = epoch_day_from_iso(end_iso)
# Every holiday is validated even when it falls outside the interval: a typo
# in a holiday calendar should fail loudly on the next run, not lie dormant
# until the year the date is finally inside a query.
excluded = {epoch_day_from_iso(holiday) for holiday in holidays}
if start > end:
return -_count_working_days(end, end_iso, start, excluded)
return _count_working_days(start, start_iso, end, excluded)
def _count_working_days(from_day: int, from_iso: str, to_day: int, excluded: Set[int]) -> int:
"""Working days in the half-open interval [from_day, to_day)."""
# The weekday is carried forward rather than recomputed per day: one date
# parse, then a seven-day cycle, which is the same loop in all three
# languages and cannot drift between them.
weekday = day_of_week(from_iso)
count = 0
for day in range(from_day, to_day):
if weekday <= 5 and day not in excluded:
count += 1
weekday = 1 if weekday == 7 else weekday + 1
return countInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add dates.business-days-between
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./dates.business-days-between-1.0.0-python.fune, or fetch it from a terminal with fune pull dates.business-days-between@1.0.0:python.
The whole function, every language, is one file too: dates.business-days-between-1.0.0.fune, 14,702 bytes, sha256 350624abbac7ca1f91a54f9cf79a8bb6f43b98eef82b4135be420f139c178a88. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before dates.business-days-between
after — your function gets the result and the arguments, and returns the final result.
# fune: after dates.business-days-between
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace dates.add-days in dates.business-days-between
# fune: replace dates.day-of-week in dates.business-days-between
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show dates.business-days-between --steps.
# fune: step dates.business-days-between after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a full Monday-to-Monday week is five working days | 2026-09-14, 2026-09-21, | → | 5 |
| the same date twice is zero: the interval is half-open | 2026-09-16, 2026-09-16, | → | 0 |
| start is inclusive, so one working day spans one night | 2026-09-16, 2026-09-17, | → | 1 |
| end is exclusive: Monday to Tuesday counts Monday only | 2026-09-14, 2026-09-15, | → | 1 |
| the first half of the week composes with the second | 2026-09-14, 2026-09-16, | → | 2 |
| and the second half: 2 plus 3 is the 5 of the whole week | 2026-09-16, 2026-09-21, | → | 3 |
| a weekend on its own has no working days | 2026-09-19, 2026-09-21, | → | 0 |
| Friday to Tuesday crosses a weekend and counts two days | 2026-09-18, 2026-09-22, | → | 2 |
| two holidays inside a Christmas week | 2026-12-24, 2026-12-29, 2026-12-25, 2026-12-28 | → | 1 |
| the same holiday listed twice is only subtracted once | 2026-12-24, 2026-12-29, 2026-12-25, 2026-12-25 | → | 2 |
Show the other 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a holiday falling on a Saturday is not subtracted twice | 2026-08-28, 2026-09-01, 2026-08-29 | → | 2 |
| a holiday outside the interval is ignored | 2026-09-14, 2026-09-16, 2026-12-25 | → | 2 |
| a holiday on the start date removes the only working day | 2026-09-14, 2026-09-15, 2026-09-14 | → | 0 |
| Good Friday and Easter Monday around a weekend | 2026-04-02, 2026-04-07, 2026-04-03, 2026-04-06 | → | 1 |
| across the new year with New Year's Day as a holiday | 2026-12-28, 2027-01-04, 2027-01-01 | → | 4 |
| the 2024 leap day is an ordinary working Thursday | 2024-02-28, 2024-03-01, | → | 2 |
| 2000 had a leap day, so the same span is still two working days | 2000-02-28, 2000-03-01, | → | 2 |
| 1900 had no leap day and the span is two working days from the 27th | 1900-02-27, 1900-03-01, | → | 2 |
| a month-long span | 2026-09-14, 2026-10-14, | → | 22 |
| a reversed interval is negative, not zero | 2026-09-21, 2026-09-14, | → | -5 |
| a reversed interval respects holidays with the same magnitude | 2026-09-21, 2026-09-14, 2026-09-16 | → | -4 |
| a malformed start date is an error | 14/09/2026, 2026-09-21, | → | error: is not an ISO date |
| a malformed end date is an error | 2026-09-14, 2026-09, | → | error: is not an ISO date |
| an impossible end date is an error, not a rolled-forward guess | 2026-09-14, 2026-02-30, | → | error: is not a real calendar date |
| a malformed holiday is an error even though it is outside the interval | 2026-09-14, 2026-09-21, 25/12/2026 | → | error: is not an ISO date |
| an impossible holiday is an error | 2026-09-14, 2026-09-21, 2026-11-31 | → | error: is not a real calendar date |
More from the author
Holidays are an argument because no library knows which days your business is closed, and a bank holiday calendar is jurisdiction-specific, year-specific and sometimes employer-specific. Holidays on a weekend are not subtracted twice, duplicates in the list are harmless, and holidays outside the interval are ignored - but they are still validated, so a typo in a calendar fails on the next run rather than in the year it finally falls inside a query.
The count is a day-by-day loop rather than a closed-form week count. It is O(days) and that is a deliberate trade: the loop is the same six lines in all three languages, which makes the parity gate meaningful, and the closed form is where off-by-one errors around the start weekday live.
Files
| Path | Bytes |
|---|---|
| README.md | 1,432 |
| impl/python.py | 2,344 |
| impl/rust.rs | 2,833 |
| impl/typescript.ts | 2,383 |
| vectors.json | 3,482 |