Fix Excel Does Not Support Timezones Error
Writing a report that came from a database or an API fails at the export step:
ValueError: Excel does not support datetimes with timezones. Please ensure that datetimes are timezone unaware before writing to Excel.
The same frame writes to CSV and Parquet without complaint, and the column looks unremarkable:
>>> orders["created_at"].head(2)
0 2026-09-17 08:14:22+00:00
1 2026-09-17 09:02:41+00:00
Name: created_at, dtype: datetime64[ns, UTC]
Root Cause
Excel stores a date as a number of days since its epoch, with the fractional part as the time of day. There is no field for a UTC offset or a timezone name, so an aware timestamp cannot be represented without losing information — and pandas refuses rather than silently choosing which information to discard. That refusal is a feature: the two reasonable interpretations differ. Writing the UTC wall clock shows 08:14 for an order that a London user placed at 09:14 British Summer Time, while converting to local time first shows the time they recognise but loses the offset. A third trap sits behind both: after tz_localize(None), the values look identical to naive timestamps that were always local, so a later reader cannot tell which convention a column follows unless the report says so.
Minimal Diagnostic
Find every timezone-aware column and show what each interpretation would produce, so the choice is explicit.
# pip install "pandas>=2.2"
import pandas as pd
REPORTING_TZ = "Europe/London"
def timezone_report(frame: pd.DataFrame, reporting_tz: str = REPORTING_TZ) -> None:
aware = [c for c in frame.columns if isinstance(frame[c].dtype, pd.DatetimeTZDtype)]
naive = [c for c in frame.select_dtypes("datetime64[ns]").columns]
print(f"timezone-aware columns: {aware or 'none'}")
print(f"naive datetime columns: {naive or 'none'}")
for col in aware:
sample = frame[col].dropna().head(3)
local = sample.dt.tz_convert(reporting_tz)
print(f"\n{col} (dtype {frame[col].dtype}):")
for utc_value, local_value in zip(sample, local):
print(f" stored {utc_value} -> {reporting_tz}: {local_value:%Y-%m-%d %H:%M}"
f" | UTC wall clock: {utc_value.tz_localize(None):%Y-%m-%d %H:%M}")
offsets = frame[col].dt.tz_convert(reporting_tz).map(lambda t: t.utcoffset()).dropna().unique()
print(f" distinct offsets in {reporting_tz}: {[str(o) for o in offsets]}")
if __name__ == "__main__":
orders = pd.read_parquet("in/orders.parquet")
timezone_report(orders)
timezone-aware columns: ['created_at', 'shipped_at']
naive datetime columns: ['order_date']
created_at (dtype datetime64[ns, UTC]):
stored 2026-09-17 08:14:22+00:00 -> Europe/London: 2026-09-17 09:14 | UTC wall clock: 2026-09-17 08:14
stored 2026-11-03 08:14:22+00:00 -> Europe/London: 2026-11-03 08:14 | UTC wall clock: 2026-11-03 08:14
distinct offsets in Europe/London: ['1:00:00', '0:00:00']
The two offsets confirm the report spans a daylight-saving change: rows from September are an hour ahead of UTC, rows from November are not. Writing UTC wall clocks would shift September's times by an hour relative to what users saw.
Fix: Convert to the Reporting Timezone, Then Drop the Offset
Make the conversion explicit, rename the column so its meaning travels with it, and keep the original instant available for anyone who needs it. Changed lines carry comments.
# pip install "pandas>=2.2" xlsxwriter
from pathlib import Path
import pandas as pd
REPORTING_TZ = "Europe/London"
def excel_ready(frame: pd.DataFrame, reporting_tz: str = REPORTING_TZ,
keep_instant: bool = True) -> pd.DataFrame:
out = frame.copy()
for col in list(out.columns):
if not isinstance(out[col].dtype, pd.DatetimeTZDtype):
continue
local = out[col].dt.tz_convert(reporting_tz) # changed: to the reader's clock
if keep_instant:
out[f"{col}_utc_iso"] = out[col].dt.tz_convert("UTC").dt.strftime("%Y-%m-%dT%H:%M:%SZ")
out[col] = local.dt.tz_localize(None) # changed: drop the offset last
suffix = reporting_tz.split("/")[-1].lower()
out = out.rename(columns={col: f"{col}_{suffix}"}) # changed: meaning in the name
return out
def write_report(frame: pd.DataFrame, dest: Path, sheet: str = "Orders") -> Path:
ready = excel_ready(frame)
dest.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(dest, engine="xlsxwriter",
datetime_format="yyyy-mm-dd hh:mm") as writer: # changed: one display format
ready.to_excel(writer, sheet_name=sheet, index=False)
ws = writer.sheets[sheet]
for i, col in enumerate(ready.columns):
width = max(len(str(col)) + 2, 19 if "datetime" in str(ready[col].dtype) else 12)
ws.set_column(i, i, width)
ws.freeze_panes(1, 0)
return dest
if __name__ == "__main__":
orders = pd.read_parquet("in/orders.parquet")
print(write_report(orders, Path("out/orders.xlsx")))
The order of operations matters: tz_convert first changes the instant's representation to the target zone, then tz_localize(None) removes the offset while keeping those local wall-clock values. Doing it the other way round — tz_localize(None) on a UTC column and then treating it as local — silently shifts every value by the offset, which is the most common way this "fix" introduces a bug.
Renaming to created_at_london costs nothing and answers the question every recipient eventually asks. The ISO instant column keeps the export lossless: anyone reconciling with system logs can read it back with pd.to_datetime(frame["created_at_utc_iso"], utc=True).
Variant Fix 1: Mixed and Per-Row Timezones
Data from several regions may carry one timezone per row — a timezone column beside a naive timestamp, or an object column of differently-offset timestamps. Normalise to a single instant before converting:
# pip install "pandas>=2.2"
import pandas as pd
def normalise_per_row_tz(frame: pd.DataFrame, value_col: str, tz_col: str,
reporting_tz: str = REPORTING_TZ) -> pd.Series:
"""Naive local times plus a timezone name per row -> one reporting-timezone series."""
parts = []
for tz_name, group in frame.groupby(tz_col, dropna=False):
naive = pd.to_datetime(group[value_col], errors="coerce")
if pd.isna(tz_name):
parts.append(naive) # unknown zone: leave as given
continue
localized = naive.dt.tz_localize(tz_name, ambiguous="NaT", nonexistent="shift_forward")
parts.append(localized.dt.tz_convert(reporting_tz).dt.tz_localize(None))
return pd.concat(parts).sort_index()
ambiguous="NaT" marks the hour that occurs twice when clocks go back — a genuinely undecidable value that should surface as missing rather than be guessed, and nonexistent="shift_forward" handles the hour that does not exist in spring. Count the resulting NaT values and report them; in most datasets there are a handful per year, and each one is a real ambiguity in the source data.
Variant Fix 2: Writing UTC Deliberately
Reports that are compared with server logs should stay in UTC — but say so in the file, because a bare 08:14 is otherwise read as local time:
# pip install "pandas>=2.2" xlsxwriter
from pathlib import Path
import pandas as pd
def write_utc_report(frame: pd.DataFrame, dest: Path, sheet: str = "Events") -> Path:
out = frame.copy()
for col in [c for c in out.columns if isinstance(out[c].dtype, pd.DatetimeTZDtype)]:
out[col] = out[col].dt.tz_convert("UTC").dt.tz_localize(None)
out = out.rename(columns={col: f"{col}_utc"})
dest.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(dest, engine="xlsxwriter", datetime_format="yyyy-mm-dd hh:mm:ss") as writer:
out.to_excel(writer, sheet_name=sheet, index=False, startrow=1)
book, ws = writer.book, writer.sheets[sheet]
note = book.add_format({"italic": True, "font_color": "#475569"})
ws.write(0, 0, "All timestamps are UTC (no daylight saving)", note) # changed: state the convention
for i, col in enumerate(out.columns):
ws.write(1, i, str(col))
return dest
A one-line note above the header is the cheapest possible defence against a misreading that otherwise surfaces weeks later in a support ticket. Where the workbook feeds another system rather than a person, keep the ISO string column instead — text is unambiguous, and the consuming code parses it explicitly.
Reading Such Files Back
A report written in local time is read back as naive timestamps, and code that compares them with UTC data must re-localise:
# pip install "pandas>=2.2"
import pandas as pd
def read_report(path: str, local_cols: tuple[str, ...] = ("created_at_london",),
reporting_tz: str = REPORTING_TZ) -> pd.DataFrame:
frame = pd.read_excel(path)
for col in local_cols:
if col in frame.columns:
frame[col] = (pd.to_datetime(frame[col], errors="coerce")
.dt.tz_localize(reporting_tz, ambiguous="NaT", nonexistent="shift_forward"))
for col in [c for c in frame.columns if c.endswith("_utc_iso")]:
frame[col.replace("_iso", "")] = pd.to_datetime(frame[col], utc=True, errors="coerce")
return frame
Where both columns exist, prefer the ISO instant: it round-trips exactly, while re-localising local times inherits the same ambiguity problems as the original conversion. This is the practical argument for always exporting the extra column — it makes the file usable by both people and code without either compromising.
Verification
Assert that no aware columns remain, that the conversion preserved the instants, and that the offset applied matches the reporting timezone at each row's date.
# pip install "pandas>=2.2"
import pandas as pd
def verify_excel_ready(original: pd.DataFrame, ready: pd.DataFrame,
col: str = "created_at", reporting_tz: str = REPORTING_TZ) -> None:
aware_left = [c for c in ready.columns if isinstance(ready[c].dtype, pd.DatetimeTZDtype)]
assert not aware_left, f"timezone-aware columns would still fail to_excel: {aware_left}"
local_col = f"{col}_{reporting_tz.split('/')[-1].lower()}"
assert local_col in ready.columns, f"expected renamed column {local_col}"
recovered = ready[local_col].dt.tz_localize(reporting_tz, ambiguous="NaT", nonexistent="NaT")
expected = original[col].dt.tz_convert(reporting_tz)
comparable = recovered.notna() & expected.notna()
assert (recovered[comparable] == expected[comparable]).all(), "local times do not match the source instants"
iso_col = f"{col}_utc_iso"
if iso_col in ready.columns:
back = pd.to_datetime(ready[iso_col], utc=True)
assert (back == original[col].dt.tz_convert("UTC")).all(), "ISO instant column does not round-trip"
ambiguous = int((~comparable & original[col].notna()).sum())
print(f"verified; {ambiguous} row(s) fall in an ambiguous or non-existent local hour")
Reporting the ambiguous-hour count rather than asserting it is zero is deliberate: a report covering the night clocks change will legitimately contain such rows, and the number belongs in the job's log so nobody is surprised when two rows show the same local time.
FAQ
Can I keep the offset by writing a string? Yes — an ISO 8601 string keeps everything, at the cost of Excel treating it as text. That is the right trade for machine-read files.
Does xlsxwriter support timezones?
No engine does; the limitation is the file format, not the library.
What about dates without times?
Pure dates have no timezone and never raise. The error only affects datetime64[ns, tz] columns.
Why did my column become timezone-aware in the first place?
Database drivers, APIs and pd.to_datetime(..., utc=True) all produce aware columns. That is good for storage and arithmetic — convert only at the export boundary.
Related
- Working with Excel Dates and Number Formats — serial numbers, formats and display
- Fix Excel Dates Showing as Numbers in pandas — the reading-side counterpart
- Normalize Inconsistent Date Formats in CSV — parsing dates from mixed sources
- Extract Comments and Tracked Changes from DOCX — another source of timezone-aware timestamps headed for Excel