Convert Excel Time Values and Durations in Python
Timesheets, call logs and machine run times all end up in Excel as times and durations — and all of them are stored the same way underneath: as a fraction of a day. 09:00 is 0.375, a 90-minute call is 0.0625, and a 30-hour job is 1.25. What pandas gives you back depends on how each cell was formatted: a datetime.time, a Timedelta, or a date in January 1900. This guide, part of Working with Dates and Times in Excel Data, converts every variant into timedeltas you can add, average and compare, and writes them back so Excel shows hours rather than a bare number.
Prerequisites
pip install pandas openpyxl
A timesheet with every variant — times of day, a shift that crosses midnight, durations with and without square brackets, durations typed as text and decimal hours:
import datetime as dt
import pandas as pd
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.append(["task", "start", "end", "break", "run_time", "typed", "hours_dec"])
for row in [("A", 0.375, 0.5208333, 0.0208333, 1.25, "2:30", 2.5),
("B", 0.875, 0.0625, 0.0104167, 0.0625, "45 min", 0.75),
("C", 0.5, 0.75, 0.0208333, 2.5, "1h 15m", 1.25)]:
ws.append(list(row))
for row in ws.iter_rows(min_row=2):
row[1].number_format = row[2].number_format = "hh:mm"
row[3].number_format = "h:mm"
row[4].number_format = "[h]:mm"
wb.save("timesheet.xlsx")
df = pd.read_excel("timesheet.xlsx")
print(df.dtypes.to_dict())
start, end and break come back as object columns of datetime.time values, run_time — formatted with square brackets — as a proper timedelta64 column, typed as text and hours_dec as floats.
Step 1 — Turn times of day into timedeltas
datetime.time objects cannot be subtracted from each other. Convert them to timedeltas since midnight by counting their seconds:
def time_to_td(series):
seconds = series.map(lambda t: t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
if pd.notna(t) else None)
return pd.to_timedelta(seconds, unit="s").round("s")
start = time_to_td(df["start"])
end = time_to_td(df["end"])
print(start.tolist()[:2]) # [Timedelta('0 days 09:00:00'), Timedelta('0 days 21:00:00')]
The function counts the seconds since midnight and rounds to the nearest whole second. That rounding matters: a time typed as 12:30 is stored as a binary fraction that openpyxl reads as 12:29:59.997, and truncating instead of rounding would lose a second on every such value. Drop the .round("s") only if your data genuinely records fractions of a second.
Step 2 — Shifts that cross midnight
Task B starts at 21:00 and ends at 01:30. Subtracting gives a negative duration. Add a day wherever the end is earlier than the start:
worked = end - start
worked = worked.where(worked >= pd.Timedelta(0), worked + pd.Timedelta(days=1))
breaks = time_to_td(df["break"])
net = worked - breaks
print((net / pd.Timedelta(hours=1)).round(2).tolist()) # [3.0, 4.25, 5.5]
Dividing a timedelta by pd.Timedelta(hours=1) gives decimal hours, the form payroll and billing systems usually want. The rule "negative means next day" assumes no shift is 24 hours or longer; for those, store full start and end datetimes rather than times of day.
Once everything is a timedelta, totals and averages are ordinary pandas operations, and they stay exact — no fractions of a day to round:
print(net.sum(), "|", net.mean()) # 0 days 12:45:00 | 0 days 04:15:00
print(net.sum() > pd.Timedelta(hours=12)) # True
Comparisons with pd.Timedelta(hours=12) read like the business rule they implement — "more than twelve hours this week" — which is harder to see in a formula comparing a cell with 0.5.
Step 3 — Durations formatted without square brackets
The break column holds breaks of 30 and 15 minutes formatted as h:mm, so they come back as times of day — 00:30 and 00:15 — which Step 1 already handled. The real trap is a duration over 24 hours with that format. openpyxl then returns a date in January 1900:
from openpyxl import load_workbook
wb = Workbook()
ws = wb.active
ws["A1"] = 1.25 # 30 hours
ws["A1"].number_format = "h:mm"
wb.save("wrapped.xlsx")
value = load_workbook("wrapped.xlsx").active["A1"].value
print(value) # 1900-01-01 06:00:00
print(value - dt.datetime(1899, 12, 31)) # 1 day, 6:00:00
Excel shows "6:00" for that cell too — the h:mm format wraps at 24 hours — but the stored value is 30 hours. Subtracting 31 December 1899, the day before serial number 1, recovers it. Check every duration column for values that came back as datetime, because a job that ran for 30 hours can otherwise be reported as six. A quick df[col].map(type).value_counts() shows at a glance whether a column mixes times, timedeltas and datetimes. Excel's serial numbers in general are covered in Fix Excel Serial Numbers Showing Instead of Dates.
Step 4 — Durations typed as text and decimal hours
pd.to_timedelta parses many text forms directly — "1h 15m", "45 min", "90 minutes" — but not "2:30", which it expects as "2:30:00". Pad colon forms with seconds, then parse:
typed = df["typed"].str.strip().str.replace(r"^(\d+):(\d{2})$", r"\1:\2:00", regex=True)
parsed = pd.to_timedelta(typed, errors="coerce")
print(parsed.tolist())
# [Timedelta('0 days 02:30:00'), Timedelta('0 days 00:45:00'), Timedelta('0 days 01:15:00')]
print(df.loc[parsed.isna(), "typed"].tolist()) # [] - nothing failed
hours = pd.to_timedelta(df["hours_dec"], unit="h")
print(hours.tolist()[0]) # 0 days 02:30:00
errors="coerce" turns anything unparseable into NaT; list those values rather than letting them drop out of a total. Decimal hours convert with unit="h". Decide once which unit a numeric column is in: 2.5 could be hours, days or minutes, and only the sheet's owner knows.
Step 5 — Write durations back so Excel shows hours
pandas writes timedeltas to Excel as a number of days with a plain number format, so 1.25 days appears as "1" in the cell. Set a duration format on those cells after writing:
out = pd.DataFrame({"task": df["task"], "net": net, "run_time": df["run_time"]})
out.to_excel("hours.xlsx", index=False)
wb = load_workbook("hours.xlsx")
ws = wb.active
for row in ws.iter_rows(min_row=2, min_col=2, max_col=3):
for cell in row:
cell.number_format = "[h]:mm"
wb.save("hours.xlsx")
cell = load_workbook("hours.xlsx").active["C2"]
print(cell.value, cell.number_format) # 1 day, 6:00:00 [h]:mm
The square brackets make Excel show 30:00 instead of wrapping to 6:00, and the stored value — a fraction of a day — lets Excel users sum and average the column with ordinary formulas. If the report is for a payroll system rather than people, write decimal hours instead; see Format Numbers in Thousands and Millions in Excel for number formats in general.
Common pitfalls
- Summing time objects.
datetime.timevalues cannot be added; pandas raises or silently returns an object column. Convert to timedeltas first. - Formats that wrap at 24 hours.
h:mmshows 30 hours as 6:00 in Excel and openpyxl reads it as a 1900 date. Use[h]:mmfor durations everywhere. - Floating-point seconds. 0.5208333 days is 12:29:59.997; round to whole seconds or minutes before comparing durations.
- The 1904 date system. Workbooks created on older Macs count days from 1904, so the same serial number means a different date. Durations are unaffected, but a wrapped duration recovered with the 1899-12-31 trick needs the 1904 epoch instead; openpyxl exposes it as
wb.epoch. - Durations stored as text by an export. Some systems write "02:30:00" as a string.
pd.to_timedeltaparses it directly, so check the column type before assuming a conversion is needed. - Mixing time zones. Times of day have no zone; if shifts span a daylight-saving change, store full timestamps — see Handle Timezones in Excel Timestamps with Python.
Conclusion
Excel times and durations are fractions of a day, and their Python type depends on the cell format. Convert times of day to timedeltas since midnight, add a day to negative shift lengths, recover wrapped durations that arrive as 1900 dates, parse text durations after padding colon forms, and convert decimal hours with unit="h". When writing back, give duration cells an [h]:mm format so Excel shows hours, not a bare number of days.
Frequently asked questions
Why does pandas read an Excel duration as a time instead of a timedelta? The type depends on the cell's number format. A cell formatted h:mm or hh:mm is read as a time of day, while h:mm (with square brackets) is read as a timedelta. Convert time objects to timedeltas by counting the seconds since midnight.
Why does a 30-hour duration come back as 1900-01-01 06:00? Excel stores 30 hours as 1.25 days. With a plain h:mm format, openpyxl treats the value as a date and time — day 1 of the 1900 date system at 06:00. Subtract datetime(1899, 12, 31) to recover the duration.
How do I calculate hours for a shift that ends after midnight? Subtract start from end; if the result is negative, add one day. Then divide the timedelta by pd.Timedelta(hours=1) to get decimal hours.
Why do my durations show as 1 or 0 in Excel after writing them with pandas? pandas writes timedeltas as a number of days with a plain number format. Set the cells' number format to h:mm:ss afterwards so Excel displays them as hours and minutes.
Related
- Up one level: Working with Dates and Times in Excel Data — dates and times from Excel.
- Parse Excel Dates into Python datetimes with pandas — the date side of the same problem.
- Calculate Business Days Between Excel Dates with pandas — elapsed working time across days.
- Match Excel Rounding in pandas — rounding decimal hours like Excel.
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