How-to
Advanced Data Transformation And CleaningDeep dive

Calculate Working Hours Between Two Timestamps

Measure support tickets, approvals and orders against service levels in business hours: count only the time between opening and closing on working days, skip weekends and holidays, handle requests raised overnight, check a vectorised version against a simple one, flag SLA breaches and write the results to Excel.

"Resolved within 8 working hours" is how most service levels are written, and it is not the same as "resolved within 8 hours". A ticket opened at 16:45 on a Friday and resolved at 10:15 on Monday took 65 and a half hours on the clock, but only 2 hours of business time if the office is open from 09:00 to 17:30. Excel has NETWORKDAYS for whole days and nothing built in for hours, which is why SLA spreadsheets are full of long nested formulas that nobody wants to check. In Python the rule can be written once, readably, and then applied to every ticket. This page is part of Working with Dates and Times in Excel Data.

Prerequisites

Bash
pip install pandas numpy openpyxl xlsxwriter

tickets.xlsx holds eight support tickets with the time each was opened and resolved and a priority. They are chosen to hit the awkward cases: a ticket raised late on a Friday, one raised in the middle of the night, one raised and closed on a Saturday, one resolved before the office opened, one that runs across a bank holiday, and one still open. The office works 09:00–17:30, Monday to Friday, and Monday 31 August 2026 is a holiday.

Friday 16:45 to Tuesday 10:15 — two business hours A timeline for one ticket. It is opened on Friday at 16:45, which leaves 45 minutes of business time that day; Saturday, Sunday and the Monday holiday count for nothing; on Tuesday it is resolved at 10:15, adding 1 hour 15 minutes. Fri 16:45 opened — 0.75 h until 17:30 Sat and Sun weekend — 0 h Mon 31 Aug holiday — 0 h Tue 10:15 resolved — 1.25 h since 09:00

Step 1 — A clear reference version

Write the rule in the most obvious way first: walk day by day from the opening date to the closing date, and on each working day add the part of the interval that falls inside office hours:

Python
import numpy as np
import pandas as pd

OPEN, CLOSE = 9.0, 17.5                                   # 09:00 to 17:30
HOLIDAYS = np.array(["2026-08-31"], dtype="datetime64[D]")

def business_hours_loop(start, end):
    if pd.isna(start) or pd.isna(end) or end <= start:
        return np.nan if pd.isna(end) else 0.0
    total, day = 0.0, start.normalize()
    while day <= end.normalize():
        if np.is_busday(np.datetime64(day.date()), holidays=HOLIDAYS):
            a = max(start, day + pd.Timedelta(hours=OPEN))
            b = min(end, day + pd.Timedelta(hours=CLOSE))
            if b > a:
                total += (b - a).total_seconds() / 3600
        day += pd.Timedelta(days=1)
    return total

t = pd.read_excel("tickets.xlsx", parse_dates=["Opened", "Resolved"])
t["Clock hours"] = ((t["Resolved"] - t["Opened"]).dt.total_seconds() / 3600).round(2)
t["Business hours"] = [round(business_hours_loop(s, e), 2) for s, e in zip(t["Opened"], t["Resolved"])]
print(t[["Ticket", "Opened", "Resolved", "Clock hours", "Business hours"]].to_string(index=False))

np.is_busday decides whether a date is a working day, using the weekend mask — Monday to Friday by default — and the holiday list. On each such day, the interval is clipped to the office hours: the later of the ticket's start and 09:00, the earlier of its end and 17:30. Anything outside that window adds nothing. The results show the difference: T-5002 is 89.5 clock hours — it spans a weekend and the holiday — but 2 business hours; T-5003, raised on Friday night, starts counting only on Tuesday morning; T-5004, raised and closed on a Saturday, has zero business hours; T-5006, open for most of a week, is 32 business hours. The open ticket gets NaN rather than a number — it has not finished.

Step 2 — A vectorised version for large sheets

The loop is easy to check and slow for hundreds of thousands of tickets. The vectorised version counts full working days between the first and last day with np.busday_count, and adds the partial first and last days:

Python
def business_hours(start, end):
    s, e_raw = pd.to_datetime(start), pd.to_datetime(end)
    e = e_raw.fillna(s)                                   # open items: measure nothing, mask later
    sd, ed = s.dt.normalize(), e.dt.normalize()
    hour_of = lambda ts, d: ((ts - d).dt.total_seconds() / 3600).clip(OPEN, CLOSE)
    sh, eh = hour_of(s, sd), hour_of(e, ed)
    as_days = lambda x: x.values.astype("datetime64[D]")
    s_bus = np.is_busday(as_days(sd), holidays=HOLIDAYS)
    e_bus = np.is_busday(as_days(ed), holidays=HOLIDAYS)
    full_days = np.busday_count(as_days(sd + pd.Timedelta(days=1)), as_days(ed), holidays=HOLIDAYS)
    same_day = np.where(s_bus, (eh - sh).clip(lower=0), 0.0)
    multi_day = (np.where(s_bus, CLOSE - sh, 0.0) + np.where(e_bus, eh - OPEN, 0.0)
                 + np.clip(full_days, 0, None) * (CLOSE - OPEN))
    hours = np.where(sd == ed, same_day, multi_day)
    return pd.Series(np.where(e > s, hours, 0.0), index=s.index).where(e_raw.notna())

t["Business hours (fast)"] = business_hours(t["Opened"], t["Resolved"]).round(2)
print((t["Business hours"].fillna(-1) == t["Business hours (fast)"].fillna(-1)).all())

Clipping each timestamp's hour of day to the 09:00–17:30 window handles tickets raised overnight: a ticket opened at 21:10 is treated as opened at 17:30 on that day, which contributes nothing, and one resolved at 08:45 as resolved at 09:00. busday_count(a, b) counts working days from a up to but not including b — exactly the full days strictly between the first and last day. The last line checks the fast version against the loop on the sample; before relying on it, run the same comparison on a few thousand random intervals, which is how this version was tested.

How the fast version adds up a multi-day ticket Three layers. The first day contributes from the clipped start time to 17:30 if it is a working day, the full working days in between contribute 8.5 hours each, and the last day contributes from 09:00 to the clipped end time if it is a working day. First day 17:30 − clipped start, if a working day Days in between busday_count × 8.5 h Last day clipped end − 09:00, if a working day

Step 3 — Flag SLA breaches

Service levels usually depend on priority. Map each priority to its target and compare; tickets still open are measured up to now, so a ticket can breach before it is closed:

Python
SLA = {"High": 4, "Normal": 8, "Low": 24}
now = pd.Timestamp("2026-09-01 16:00")
t["Measured to"] = t["Resolved"].fillna(now)
t["Hours used"] = business_hours(t["Opened"], t["Measured to"]).round(2)
t["Target"] = t["Priority"].map(SLA)
t["Breach"] = t["Hours used"] > t["Target"]
t["Status"] = np.where(t["Resolved"].isna(), "open", "closed")
print(t[["Ticket", "Priority", "Status", "Hours used", "Target", "Breach"]].to_string(index=False))
print(f"breach rate: {t['Breach'].mean():.0%}")

Two tickets breach. T-5006 is plain: 32 business hours against a 24-hour target. T-5005 is the instructive one: raised at 08:15 on Wednesday, before the office opened, and resolved at 08:45 on Thursday, again before opening — every minute of Wednesday's office day counts, so it used 8.5 hours against a target of 8, although it was fixed before anyone arrived on Thursday. Whether that is a fair breach is a policy question the numbers make visible. Using the report's run time for open tickets makes the flag answer "is this ticket already out of SLA?", which is what a team lead wants from a morning report. Freeze now to the report's cut-off time rather than calling pd.Timestamp.now() in several places, so every row is measured against the same moment — and so the report can be rerun for yesterday with the same result, as in Make Excel Output Reproducible.

Step 4 — Write the SLA report

Python
cols = ["Ticket", "Priority", "Opened", "Resolved", "Status", "Clock hours", "Hours used", "Target", "Breach"]
with pd.ExcelWriter("sla_report.xlsx", engine="xlsxwriter", datetime_format="ddd dd mmm hh:mm") as xw:
    t[cols].to_excel(xw, sheet_name="Tickets", index=False)
    wb, ws = xw.book, xw.sheets["Tickets"]
    red = wb.add_format({"bg_color": "#FCE9E9", "font_color": "#9C0006"})
    ws.conditional_format(1, 0, len(t), len(cols) - 1, {"type": "formula", "criteria": "=$I2=TRUE", "format": red})
    ws.set_column("A:B", 10)
    ws.set_column("C:D", 18)
    summary = t.groupby("Priority").agg(Tickets=("Ticket", "count"), Breaches=("Breach", "sum"),
                                        Median_hours=("Hours used", "median"))
    summary.to_excel(xw, sheet_name="Summary")
print(summary)
The Tickets sheet of the SLA report Rows for several tickets with priority, clock hours, business hours used and target. Tickets whose business hours exceed the target are highlighted in red. A B C D E F 1 Ticket Priority Clock hours Hours used Target Breach 2 T-5002 Normal 89.5 2.0 8 FALSE 3 T-5003 High 84.5 0.67 4 FALSE 4 T-5006 Low 94.0 32.0 24 TRUE 5 T-5008 Normal open 6.5 8 FALSE

Common pitfalls

  • Clock time instead of business time. Subtracting timestamps counts nights and weekends; clip to office hours on working days.
  • Forgetting holidays. A bank holiday inside an interval adds a full day to the result unless it is in the holiday list.
  • Timezones. Tickets logged in UTC and an office working in local time give answers off by hours. Convert to the office's timezone and drop the timezone before calculating — see Handle Timezones in Excel Timestamps with Python.
  • Lunch breaks and shifts. Split days are several windows per day; loop over the windows in the reference version and sum.
  • Different calendars per team. A team working Sunday to Thursday needs weekmask="Sun Mon Tue Wed Thu" in is_busday and busday_count.

Performance notes

The loop version handles a few thousand tickets in a fraction of a second, which is enough for most weekly reports. The vectorised version does 100,000 tickets in well under a second, because everything is a whole-column operation. Keep both: the loop is the specification that people can read and check against a handful of real tickets, and the vectorised function is tested against it.

Conclusion

Define office hours, working days and holidays once. Count business time by clipping each interval to the office window on each working day — as a loop for clarity, and with np.busday_count plus partial first and last days for speed — and check the two against each other. Map priorities to targets, measure open tickets up to a fixed report time, flag breaches and write the tickets and a per-priority summary to Excel.

Frequently asked questions

How do I calculate business hours between two datetimes in Python? For each working day between the two dates, add the part of the interval that falls inside office hours, skipping weekends and holidays with numpy.is_busday. For many rows, count the full working days with numpy.busday_count and add the partial first and last days.

Does Excel have a function for working hours? Not directly. NETWORKDAYS and NETWORKDAYS.INTL count whole working days; working hours need a longer formula that combines them with the start and end times, which is why many teams calculate it in Python instead.

How do I exclude public holidays? Pass an array of holiday dates to numpy.is_busday and numpy.busday_count through the holidays argument. Keep the list in a file or a sheet that someone maintains for each year.

What if the office works different days? Use the weekmask argument, for example weekmask="Sun Mon Tue Wed Thu" for a Sunday-to-Thursday week. Different hours per day, or split shifts, are easiest in the loop version with a list of windows per weekday.

More on dates & times

More on dates & times →