Calculate Business Days Between Excel Dates with pandas
Service-level reports live and die by working days: how many business days a ticket stayed open, whether an
invoice was paid within thirty working days, when a request is due if it arrived on a Friday. In Excel those are
NETWORKDAYS and WORKDAY, filled down a column with a holiday range on another sheet. Moving the report to
Python means reproducing them exactly — including the off-by-one rules that make a careless translation disagree
with the spreadsheet it replaces. This guide, part of
Working with Dates and Times in Excel Data,
uses NumPy's business-day functions, which are vectorised and fast.
Prerequisites
pip install pandas numpy openpyxl
A ticket log and a holiday list, written as a workbook with two sheets the way these reports are usually organised:
import pandas as pd
tickets = pd.DataFrame({
"Ticket": ["T-101", "T-102", "T-103", "T-104", "T-105"],
"Opened": pd.to_datetime(["2026-09-07", "2026-09-11", "2026-12-24", "2026-09-12", None]),
"Closed": pd.to_datetime(["2026-09-11", "2026-09-14", "2027-01-04", "2026-09-12", "2026-10-01"]),
})
holidays = pd.DataFrame({"Holiday": pd.to_datetime(["2026-12-25", "2026-12-28", "2027-01-01"])})
with pd.ExcelWriter("service_desk.xlsx") as writer:
tickets.to_excel(writer, sheet_name="Tickets", index=False)
holidays.to_excel(writer, sheet_name="Holidays", index=False)
Step 1 — Count business days with busday_count
np.busday_count(begin, end) counts Monday-to-Friday days in the half-open interval from begin up to but not including end. It works on arrays of
datetime64[D], so a whole column is one call.
import numpy as np
frame = pd.read_excel("service_desk.xlsx", sheet_name="Tickets", parse_dates=["Opened", "Closed"])
valid = frame[["Opened", "Closed"]].notna().all(axis=1)
begin = frame.loc[valid, "Opened"].to_numpy(dtype="datetime64[D]")
end = frame.loc[valid, "Closed"].to_numpy(dtype="datetime64[D]")
print(np.busday_count(begin, end)) # [4 1 7 0]
Converting to datetime64[D] drops the time of day, which is also what Excel does when a date cell carries a time. The valid mask matters because busday_count
cannot handle NaT: ticket T-105 has no opening date and must be kept out of the call.
Step 2 — Match NETWORKDAYS by counting both ends
Excel's NETWORKDAYS(start, end) counts both the start and end dates when they are working days: Monday to Friday of the same week is 5, not 4. To match it, move the
end one day later before calling busday_count.
def networkdays(start: pd.Series, end: pd.Series, holidays=None) -> pd.Series:
result = pd.Series(pd.NA, index=start.index, dtype="Int64")
ok = start.notna() & end.notna()
counts = np.busday_count(
start[ok].to_numpy(dtype="datetime64[D]"),
(end[ok] + pd.Timedelta(days=1)).to_numpy(dtype="datetime64[D]"),
holidays=[] if holidays is None else holidays,
)
result[ok] = counts
return result
frame["WorkingDays"] = networkdays(frame["Opened"], frame["Closed"])
print(frame[["Ticket", "Opened", "Closed", "WorkingDays"]])
Ticket T-101 now reads 5, as it would in the spreadsheet. T-104 opened and closed on a Saturday and correctly reads 0. T-105 stays <NA> instead of raising, which lets the
report show it as "not measurable" rather than crashing the job.
Step 3 — Exclude holidays from a sheet
Both functions take a holidays array. Read it from the workbook's holiday sheet so the finance team can maintain it without touching code, and convert it to
datetime64[D] once.
holiday_days = (
pd.read_excel("service_desk.xlsx", sheet_name="Holidays")["Holiday"]
.dropna()
.to_numpy(dtype="datetime64[D]")
)
frame["WorkingDays"] = networkdays(frame["Opened"], frame["Closed"], holidays=holiday_days)
print(frame[["Ticket", "WorkingDays"]])
# T-103, open over Christmas and New Year, counts 5 instead of 8
Holidays that fall on a weekend are ignored automatically, so there is no need to filter the list. For a report spanning many years and countries, a maintained source
is less error-prone than a typed list; the holidays Python package provides public holidays by country and year, and its dates convert the same way.
Step 4 — Use a custom weekend like NETWORKDAYS.INTL
Not every organisation works Monday to Friday. NETWORKDAYS.INTL takes a weekend code; NumPy takes a weekmask, either seven 0/1 characters starting on Monday or
a list of day names.
sunday_to_thursday = "Sun Mon Tue Wed Thu"
six_day_week = "1111110" # Monday to Saturday
start = np.datetime64("2026-09-06") # a Sunday
end = np.datetime64("2026-09-12") # the following Saturday
one_day = np.timedelta64(1, "D")
print(np.busday_count(start, end + one_day, weekmask=sunday_to_thursday)) # 5
print(np.busday_count(start, end + one_day, weekmask=six_day_week)) # 6
A different weekmask per row — offices in several countries in one report — is easiest handled by grouping on the country column and calling the function once per group with that country's mask and holiday list.
Step 5 — Add working days to a date like WORKDAY
Deadlines go the other way: a request received on a date is due a number of working days later. np.busday_offset(date, n) moves by n business days. The roll argument
decides what happens when the start date itself is not a working day, and matching Excel's WORKDAY needs roll="backward" for positive offsets.
def workday(start: pd.Series, days: int, holidays=None) -> pd.Series:
result = pd.Series(pd.NaT, index=start.index, dtype="datetime64[ns]")
ok = start.notna()
shifted = np.busday_offset(
start[ok].to_numpy(dtype="datetime64[D]"), days,
roll="backward" if days >= 0 else "forward",
holidays=[] if holidays is None else holidays,
)
result[ok] = pd.to_datetime(shifted)
return result
frame["DueBy"] = workday(frame["Opened"], 3, holidays=holiday_days)
print(frame[["Ticket", "Opened", "DueBy"]])
For T-104, opened on Saturday 12 September, the due date is Wednesday 16 September — three working days counted from the weekend, the same answer WORKDAY gives. With the
default roll="raise" NumPy would refuse the Saturday date entirely, and with roll="forward" it would first move to Monday and then add three days, landing a day late.
Step 6 — Flag SLA breaches and write the result
With counts and due dates as columns, the service report is a comparison and a write. Keep the working-day count as a number and let Excel format the dates.
SLA_DAYS = 3
frame["Breached"] = frame["WorkingDays"].map(
lambda days: pd.NA if pd.isna(days) else ("Yes" if days > SLA_DAYS else "No")
)
with pd.ExcelWriter("sla_report.xlsx", engine="openpyxl", datetime_format="yyyy-mm-dd") as writer:
frame.to_excel(writer, sheet_name="SLA", index=False)
print(pd.read_excel("sla_report.xlsx")[["Ticket", "WorkingDays", "Breached"]])
Leaving Breached as missing for tickets without both dates keeps them out of breach counts and makes the data gap visible in the output. Formatting the date columns further is
covered in Format Dates in Excel Cells with Python.
Step 7 — Build one business calendar and reuse it
Real reports rarely need a single count. They need working days open, a due date, working days overdue, and perhaps working days per month for capacity figures — all
under the same weekend and holiday rules. A np.busdaycalendar bundles those rules into one object, validated once, that every business-day function accepts through
busdaycal=. Changing the rules then means changing one line.
calendar = np.busdaycalendar(weekmask="Mon Tue Wed Thu Fri", holidays=holiday_days)
opened = frame.loc[valid, "Opened"].to_numpy(dtype="datetime64[D]")
closed = frame.loc[valid, "Closed"].to_numpy(dtype="datetime64[D]")
due = np.busday_offset(opened, SLA_DAYS, roll="backward", busdaycal=calendar)
overdue = np.maximum(np.busday_count(due, closed, busdaycal=calendar), 0)
print(pd.DataFrame({"Ticket": frame.loc[valid, "Ticket"], "Due": due, "DaysOverdue": overdue}))
months = pd.period_range("2026-09", "2027-01", freq="M")
per_month = np.busday_count(months.start_time.to_numpy("datetime64[D]"),
(months.end_time + pd.Timedelta(days=1)).to_numpy("datetime64[D]"),
busdaycal=calendar)
print(dict(zip(months.astype(str), per_month)))
The overdue count uses the half-open convention on purpose: a ticket closed on its due date is zero days overdue, and one closed the next working day is one. The working days per month are a useful denominator for productivity figures, since a December with two holidays should not be compared raw with a September that has none. Per-month grouping of the tickets themselves is covered in Group Excel Rows by Month and Quarter with pandas.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Every count one lower than Excel | busday_count excludes the end date | Add one day to the end |
ValueError: Could not convert object to NumPy datetime | NaT or text dates in the column | Mask missing values; parse with pd.to_datetime |
ValueError: Non-business day date in busday_offset | Start on a weekend with default roll="raise" | Pass roll="backward" (positive) or "forward" (negative) |
| Holidays have no effect | Holiday dates are strings or timestamps with times | Convert the list to datetime64[D] |
| Negative counts | End date before start date | Validate order, or take the absolute value if intended |
| Dates shifted by timezone | Timezone-aware timestamps | Convert to local time and drop the zone first |
Performance and scale notes
busday_count and busday_offset are vectorised in C, so a million rows take a fraction of a second — far faster than apply with a Python function, and faster than generating
bdate_range per row. Building a np.busdaycalendar(weekmask=..., holidays=...) once and passing it as busdaycal= avoids re-validating the holiday list on every call when the
same calendar is used repeatedly.
Conclusion
np.busday_count and np.busday_offset are the vectorised equivalents of NETWORKDAYS and WORKDAY. Convert columns to datetime64[D], mask missing dates, and account for the
conventions: add a day to the end to count both ends like NETWORKDAYS, and roll backward for positive offsets to match WORKDAY on weekend start dates. Load holidays from the
workbook, use a weekmask for non-standard weekends, and keep missing results as missing so they never count as breaches.
Frequently asked questions
What is the pandas equivalent of NETWORKDAYS? numpy.busday_count on the start and end columns converted to datetime64D, with one day added to the end so both dates are counted as Excel does.
How do I exclude public holidays? Pass an array of holiday dates as holidays to busday_count or busday_offset. Reading them from a sheet in the same workbook lets non-developers maintain the list.
Can pandas CustomBusinessDay do the same thing? Yes, for adding working days: a CustomBusinessDay offset with holidays shifts dates correctly. For counting days between two columns, busday_count is simpler and much faster.
How do I count working hours instead of days? Count full working days between the dates, then add the partial hours of the first and last day from the timestamps. Business-hour offsets in pandas can help, but explicit arithmetic is easier to verify.
Related
- Up one level: Working with Dates and Times in Excel Data — parsing, grouping and timezones.
- Parse Excel Dates into Python datetimes with pandas — getting clean date columns first.
- Group Excel Rows by Month and Quarter with pandas — summarising SLA results by period.
- Excel Formula Equivalents in pandas — more spreadsheet functions translated.
- Excel IF Formulas as pandas Conditional Columns — building breach flags and categories.
More on dates & times
- How-toHighlight Overdue Dates in Excel with openpyxl
- How-toHighlight Weekends and Holidays in a Date Column
- How-toKeep the Latest Record per Key When Deduplicating with pandas
- How-toCalculate Moving Averages with pandas rolling
- How-toEOMONTH and EDATE Equivalents in pandas
- How-toYEARFRAC and DAYS360 Equivalents in Python