Functional Weave
Code in Python

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 days
  • business_days_between(2026-09-16, 2026-09-16, ) → 0 the same date twice is zero: the interval is half-open
  • business_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_isodateISO date, inclusive
end_isodateISO date, exclusive
holidaysdate[]non-working dates; order and duplicates do not matter
returnsintworking 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
impl/python.py · 50 lines · open · raw

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 count

Install

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
Download for Python dates.business-days-between-1.0.0-python.fune · 9,332 bytes sha256 2ff344c198ab29bea96f49312affc9c5a5b72aa58ee23dc74845a505c9fe41c8

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,432
impl/python.py2,344
impl/rust.rs2,833
impl/typescript.ts2,383
vectors.json3,482