Handle Daylight Saving Time in Excel Timestamps
A spreadsheet timestamp is a wall-clock reading: 2026-10-25 01:30. It carries no time zone and no hint of whether daylight saving time was in force. Most of the year that does not matter. Twice a year it does. When the clocks go back, the hour from 01:00 to 02:00 happens twice, so a meter that logs every half hour writes 01:30 two times; when they go forward, an hour is skipped, so 01:30 never happens at all — unless someone typed it. Subtracting two such timestamps gives durations an hour out, grouping by day puts 25 or 23 hours into one day without saying so, and pandas refuses to attach a time zone at all, raising an error that tells you to use its ambiguous or nonexistent arguments. This page shows each problem on real clock-change dates for the UK, localises the timestamps safely — inferring the repeated hour from the order of readings — computes true elapsed time, aggregates by day correctly, and writes results back for Excel. It belongs to Working with Dates and Times in Excel Data.
Prerequisites
pip install pandas openpyxl
The examples use the Europe/London time zone, whose clocks went forward at 01:00 on 29 March 2026 and go back at 02:00 on 25 October 2026; the same code works for any zone in the IANA database. The data is a half-hourly meter export across the October change and a shift log across the March change.
Step 1 — The repeated hour
import pandas as pd
times = pd.date_range("2026-10-24 23:00", periods=10, freq="30min").tolist()
times = times[:6] + [pd.Timestamp("2026-10-25 01:00"), pd.Timestamp("2026-10-25 01:30")] + [pd.Timestamp("2026-10-25 02:00"), pd.Timestamp("2026-10-25 02:30")]
meter = pd.DataFrame({"Reading time": times, "kWh": [1.2, 1.1, 1.0, 0.9, 0.9, 0.8, 0.8, 0.9, 1.0, 1.1]})
print(meter.to_string(index=False))
try:
meter["Reading time"].dt.tz_localize("Europe/London")
except Exception as e:
print(type(e).__name__ + ":", e)
The export shows 01:00 and 01:30 twice — the first pair still in British Summer Time, the second after the clocks went back to GMT. As naive timestamps they are simply duplicates, so a pivot by time would add the two readings together and a check for duplicate timestamps would flag perfectly good data. tz_localize refuses to guess which 01:00 is which and raises ValueError: Cannot infer dst time from 2026-10-25 01:00:00, try using the 'ambiguous' argument. The information needed to decide is there, though: the readings are in time order, so the first occurrence must be the summer one.
Step 2 — Localise with ambiguous="infer"
local = meter["Reading time"].dt.tz_localize("Europe/London", ambiguous="infer")
meter["Local"] = local
meter["UTC"] = local.dt.tz_convert("UTC")
meter["Offset"] = local.map(lambda t: t.strftime("%z"))
print(meter[["Reading time", "Offset", "UTC"]].to_string(index=False))
gaps = meter["UTC"].diff().dropna().dt.total_seconds().div(60).unique()
print("minutes between consecutive readings:", gaps)
ambiguous="infer" uses the order of the timestamps to decide: where the wall clock jumps backwards, it assigns the earlier copies to summer time and the later ones to winter time. The offset column shows the switch from +0100 to +0000, and in UTC the readings are exactly thirty minutes apart all the way through — proof that nothing is duplicated and nothing is missing. infer only works on data sorted in time order; for unsorted data, or a single value, pass a boolean array instead (True for daylight time) built from another column, such as a sequence number from the meter.
Step 3 — The missing hour, and real durations
In spring the opposite happens. A shift log records start and end times on the wall clock; across the change, simple subtraction is an hour out, and a mistyped time inside the gap does not exist:
shifts = pd.DataFrame({"Shift": ["Night A", "Night B", "Early"],
"Start": pd.to_datetime(["2026-03-28 22:00", "2026-03-29 00:30", "2026-03-29 01:30"]),
"End": pd.to_datetime(["2026-03-29 06:00", "2026-03-29 08:30", "2026-03-29 09:30"])})
shifts["Wall-clock hours"] = (shifts["End"] - shifts["Start"]).dt.total_seconds() / 3600
start = shifts["Start"].dt.tz_localize("Europe/London", nonexistent="NaT")
end = shifts["End"].dt.tz_localize("Europe/London", nonexistent="NaT")
shifts["Actual hours"] = (end - start).dt.total_seconds() / 3600
shifts["Start is valid"] = start.notna()
print(shifts.to_string(index=False))
The night shift from 22:00 to 06:00 looks like eight hours but lasts seven, because the clocks went forward in the middle; paying it as eight, or measuring it against an eight-hour target, is wrong by an hour. Subtracting time-zone-aware timestamps gives the real elapsed time. The early shift claims to start at 01:30 on 29 March, a time that did not exist in London that night. nonexistent="NaT" turns it into a missing value so it is flagged rather than silently moved; the alternatives "shift_forward" and "shift_backward" move it to the edge of the gap, which is right when you know the device clock skipped the hour and wrong when a person mistyped. Durations over 24 hours and shift arithmetic are covered in Calculate Working Hours Between Two Timestamps.
Step 4 — Daily totals and writing back to Excel
Grouping by local calendar day is still right — a day is a day to the people reading the report — but the day of a clock change has 23 or 25 hours, which matters for averages and for checks on reading counts:
day = meter.set_index("Local")["kWh"].groupby(lambda t: t.date()).agg(["sum", "count"])
print(day)
print("hours on 25 Oct 2026:", (pd.Timestamp("2026-10-26", tz="Europe/London") - pd.Timestamp("2026-10-25", tz="Europe/London")) / pd.Timedelta(hours=1))
out = meter.assign(**{"Local time": meter["Local"].dt.tz_localize(None), "UTC time": meter["UTC"].dt.tz_localize(None),
"Zone": meter["Offset"].map({"+0100": "BST", "+0000": "GMT"})})[["Local time", "Zone", "UTC time", "kWh"]]
out.to_excel("meter_dst.xlsx", index=False)
print(out.to_string(index=False))
Subtracting midnight from the next midnight in the London zone gives 25 hours for 25 October 2026, so a check that expects 48 half-hourly readings per day should expect 50 that day and 46 on the day in March. Excel cannot store time-zone-aware values, so before writing, remove the zone with tz_localize(None) — keeping local time for people and a UTC column for anything that needs to sort, subtract or join reliably — and add a column saying which offset applied. With Zone next to it, the repeated 01:00 is no longer a mystery to someone reading the sheet. Converting between zones more generally is covered in Handle Timezones in Excel Timestamps with Python.
Where the clock-change problems hide
Most data never touches the two awkward hours, which is why these bugs survive for years. They tend to appear in three places. Overnight operations — shifts, batch jobs, meters, call centres — have data inside the gap or the repeat every year. Durations and rates computed from naive timestamps are an hour out for anything spanning the change, which shows up as one strangely short or long shift, or a missed service-level target, twice a year. And joins on timestamps between systems where one stores UTC and the other local time drift by an hour for half the year and match for the other half, which is the hardest of all to spot. The defence is the same in each case: attach the zone as soon as the data is read, do arithmetic and joins in UTC, and convert back to local time only for display.
Common pitfalls
- Treating repeated times as duplicates. On the night the clocks go back they are distinct readings.
- Naive subtraction across a change. Localise first; real elapsed time differs by an hour.
ambiguous="infer"on unsorted data. Sort first, or pass an explicit boolean array.- Shifting nonexistent times silently. Flag them unless you know the device skipped the hour.
- Writing aware timestamps to Excel. Remove the zone and add an offset or zone column.
Conclusion
Excel timestamps are naive wall-clock times, so the repeated autumn hour and the missing spring hour must be resolved when the data is read. Localise with tz_localize(zone, ambiguous="infer") on time-ordered data, flag nonexistent times with nonexistent="NaT", compute durations from aware timestamps or in UTC, remember that clock-change days have 23 or 25 hours, and write local time, UTC time and the zone back to Excel so readers can see what happened.
Frequently asked questions
How do I fix "Cannot infer dst time" when localising Excel timestamps? Sort the timestamps and use tz_localize(zone, ambiguous="infer"), or pass a boolean array that says which ambiguous values are in daylight saving time.
How do I handle times that do not exist because the clocks went forward? Pass nonexistent="NaT" to flag them, or "shift_forward" or "shift_backward" to move them to the edge of the gap when you know the clock skipped the hour.
How do I calculate the real length of a shift across a clock change? Localise both start and end to the time zone and subtract; the result is the true elapsed time, for example seven hours instead of eight.
Can Excel store time zones? No. Store local time and UTC as separate naive columns, with a column for the zone or offset, and convert in Python when reading.
Related
- Up one level: Working with Dates and Times in Excel Data — dates and times in pandas.
- Handle Timezones in Excel Timestamps with Python — converting between zones.
- Fix "Excel does not support datetimes with timezones" in pandas — writing aware values.
- Resample Irregular Readings to Regular Intervals — meter data in general.
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