Handle Timezones in Excel Timestamps with Python
Excel has no timezone. A cell holds a day count and nothing else, so the instant 2026-08-15 18:00+02:00 and the instant 2026-08-15 18:00Z are indistinguishable once written. openpyxl handles this honestly — it raises rather than silently dropping the offset — which means every script that writes timestamps has to make a decision. This guide covers the three-step pattern that keeps those timestamps unambiguous, the daylight-saving edges that break naive code, and how to read the values back into aware datetimes. It expands on the timezone section of Working with Dates and Times in Excel Data.
Prerequisites
pip install pandas openpyxl xlsxwriter
Python 3.9 and later ship zoneinfo in the standard library, so no third-party timezone package is needed:
from zoneinfo import ZoneInfo
print(ZoneInfo("Europe/Berlin"))
On a bare Linux container without the system tz database, install the fallback: pip install tzdata.
Step 1 — See the failure clearly
openpyxl refuses aware datetimes outright:
from datetime import datetime, timezone
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws["A1"] = datetime(2026, 8, 15, 18, 0, tzinfo=timezone.utc)
# ValueError: Excel does not support timezones in datetimes.
# The tzinfo in the datetime/time object must be set to None.
pandas is quieter and therefore more dangerous. to_excel on a tz-aware column will raise on some engine and dtype combinations and silently write the naive local wall-clock time on others — so never rely on the default. Make the conversion explicit:
import pandas as pd
events = pd.DataFrame({
"event": ["login", "export", "logout"],
"at": pd.to_datetime([
"2026-08-15T16:04:00Z",
"2026-08-15T17:30:00Z",
"2026-08-15T19:15:00Z",
], utc=True),
})
print(events["at"].dtype) # datetime64[ns, UTC]
Step 2 — Convert, strip, label
The whole pattern is three lines of transformation and one line of documentation:
import pandas as pd
REPORT_ZONE = "Europe/Berlin"
# 1. Convert — same instants, expressed in the readers' zone.
events["at"] = events["at"].dt.tz_convert(REPORT_ZONE)
# 2. Strip — drop the offset so Excel can hold the value.
events["at"] = events["at"].dt.tz_localize(None)
# 3. Label — write the zone where a reader will see it.
with pd.ExcelWriter("events.xlsx", engine="xlsxwriter",
datetime_format="yyyy-mm-dd hh:mm") as writer:
events.to_excel(writer, sheet_name="Events", index=False, startrow=1)
book, sheet = writer.book, writer.sheets["Events"]
note = book.add_format({"italic": True, "font_color": "#5b6780"})
sheet.write(0, 0, f"All times {REPORT_ZONE}", note)
sheet.set_column("B:B", 19)
tz_convert and tz_localize are easy to confuse and do opposite things:
| Method | Requires | Does |
|---|---|---|
tz_localize("Europe/Berlin") | naive input | Asserts these wall-clock times are Berlin times |
tz_convert("Europe/Berlin") | aware input | Re-expresses the same instant in Berlin |
tz_localize(None) | aware input | Discards the offset, keeping the wall clock |
Calling tz_localize on already-aware data raises; calling tz_convert on naive data raises. The error messages are clear, but the conceptual mistake — localising when you meant to convert — silently shifts every timestamp by the offset when you get it the other way round.
Step 3 — Survive daylight saving
Twice a year, local time is not a function of itself. In the spring one hour does not exist; in the autumn one hour happens twice. Any code that builds aware timestamps from local strings meets this eventually.
import pandas as pd
local = pd.to_datetime(["2026-10-25 02:30", "2026-03-29 02:30"])
# Default: raises on both the ambiguous and the nonexistent value.
try:
local.tz_localize("Europe/Berlin")
except Exception as exc:
print(type(exc).__name__, exc)
# State the policy explicitly instead.
resolved = local.tz_localize(
"Europe/Berlin",
ambiguous=False, # autumn repeat: take the second (winter) pass
nonexistent="shift_forward", # spring gap: move to the first valid instant
)
print(resolved)
Choosing ambiguous=False versus True is a business decision, not a technical one — it decides whether a 02:30 event on transition night is recorded before or after the clocks go back. Passing ambiguous="NaT" is the honest option when you genuinely cannot tell, because it marks the rows rather than guessing.
All of this disappears if timestamps arrive as UTC instants. Conversion from an instant is always well defined; localisation of a wall clock is not. Where you control the upstream — a database extract, an API — capture UTC and convert only for display. See exporting SQL query results to Excel for pushing that decision into the query.
Step 4 — Read the timestamps back
A naive timestamp read from Excel is meaningless until you re-apply the zone the file was written in. If step 3 put the zone in the sheet, the round trip is exact:
import pandas as pd
# The label written at row 0; the table starts at row 1.
header = pd.read_excel("events.xlsx", sheet_name="Events", nrows=0, header=None)
label = str(header.iloc[0, 0]) if not header.empty else ""
zone = label.replace("All times", "").strip() or "UTC"
df = pd.read_excel("events.xlsx", sheet_name="Events", skiprows=1)
# Re-localise into the documented zone, then normalise to UTC for joining.
df["at"] = (
df["at"]
.dt.tz_localize(zone, ambiguous=False, nonexistent="shift_forward")
.dt.tz_convert("UTC")
)
print(df["at"].dtype) # datetime64[ns, UTC]
When rows genuinely originate in different zones, a label cannot describe them. Carry the zone per row instead:
import pandas as pd
df = pd.DataFrame({
"site": ["berlin", "denver", "singapore"],
"local_time": pd.to_datetime(["2026-08-15 20:00", "2026-08-15 12:00",
"2026-08-16 02:00"]),
"zone": ["Europe/Berlin", "America/Denver", "Asia/Singapore"],
})
# groupby keeps each zone's rows together so tz_localize is vectorised per group.
df["utc"] = (
df.groupby("zone", group_keys=False)
.apply(lambda g: g["local_time"].dt.tz_localize(g.name).dt.tz_convert("UTC"))
)
print(df[["site", "local_time", "zone", "utc"]])
That two-column shape — a readable local time plus the IANA zone name — is the most robust thing you can put in a spreadsheet. It is readable by a human, and it reconstructs the exact instant for a machine.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
ValueError: Excel does not support timezones | Writing an aware datetime | tz_localize(None) after converting. |
| Every timestamp shifted by the offset | tz_localize used where tz_convert was meant | Convert aware data; localise naive data. |
AmbiguousTimeError | Autumn transition hour | Pass ambiguous= explicitly, or "NaT" to mark them. |
NonExistentTimeError | Spring transition gap | Pass nonexistent="shift_forward". |
| Nobody can tell what zone the file uses | Label step skipped | Write the zone into a header cell or a metadata sheet. |
ZoneInfoNotFoundError in a container | No system tz database | pip install tzdata. |
| Times drift by seconds over a round trip | Binary fraction of a day | Round to the second after reading. |
| Rows from different offices compared wrongly | One zone assumed for all | Carry a per-row zone column. |
Performance and scale notes
Timezone conversion is vectorised and cheap — tz_convert on a million-row column is a single offset computation per distinct offset, not per row. The expensive operations are the ones that fall back to Python objects.
The groupby plus apply pattern above is the main one to watch: it runs once per distinct zone, which is fine for a handful of offices and slow for thousands of rows with high zone cardinality. When cardinality is high, map through the small set of distinct zones instead:
import pandas as pd
out = pd.Series(pd.NaT, index=df.index, dtype="datetime64[ns, UTC]")
for zone, idx in df.groupby("zone").groups.items():
out.loc[idx] = (
df.loc[idx, "local_time"]
.dt.tz_localize(zone, ambiguous=False, nonexistent="shift_forward")
.dt.tz_convert("UTC")
)
df["utc"] = out
Two other habits keep large jobs fast. Convert once, at the boundary, rather than inside every function that touches the column — repeated tz_convert calls allocate a new array each time. And avoid .dt.tz_localize inside an apply over rows; it constructs a fresh timezone object per call, which is roughly two orders of magnitude slower than the column-level operation.
For workbooks large enough that memory matters, do the timezone work chunk by chunk as you read, using the streaming approach in reading large Excel files in chunks with pandas. Timezone conversion is stateless per row, so it parallelises across chunks with no coordination.
Conclusion
Excel cannot hold a timezone, so your script has to. Convert aware timestamps to the one zone your readers think in, strip the offset with tz_localize(None) so openpyxl will accept them, and write the zone name into the sheet where a human will find it six months later. Keep upstream data in UTC wherever you can, because converting from an instant is always well defined while localising a wall clock is not. And when rows really do come from different places, carry the IANA zone name in its own column — that is the one representation that survives every round trip.
Frequently asked questions
Why does openpyxl raise ValueError on a timezone-aware datetime?
Because the Excel file format has no field for an offset. Rather than silently discarding information, openpyxl refuses the write and makes you decide which zone the value represents.
Should I store UTC or local time in the spreadsheet? Store what the readers will reason about. Operational reports read by one office should carry that office's local time; anything joined with other systems or spanning regions should carry UTC. Whichever you pick, say so in the sheet.
How do I record the zone so the file is self-describing? Put it in a header cell above the table, in the sheet name, or in a dedicated metadata sheet. A separate column holding the IANA zone name works when rows genuinely come from different zones.
What happens on the night the clocks change?
Local times become ambiguous or non-existent for one hour. Convert from UTC rather than localising local strings, and where you must localise, pass the ambiguous and nonexistent arguments explicitly instead of accepting the default.
Can I keep the UTC offset in a separate column? Yes, and it is a good pattern. Write the local timestamp for reading plus a text column holding the IANA zone name, so the original instant can be reconstructed exactly.
Related
- Up to the parent: Working with Dates and Times in Excel Data — the storage model and the wider date toolkit.
- Parse Excel Dates into Python datetimes with pandas — getting the column typed before you localise it.
- Group Excel Rows by Month and Quarter with pandas — why the zone choice changes which month a late-evening row lands in.
- Export SQL Query Results to Excel with Python — capturing UTC at the source.
- Fix Excel Serial Numbers Showing Instead of Dates — making the stripped timestamps display properly.