Fix
Getting Started With Python Excel AutomationDeep dive

Fix "Excel does not support datetimes with timezones" in pandas

Excel cells cannot store a time zone, so pandas refuses to write timezone-aware timestamps. Decide which clock the reader needs, convert to it, drop the zone, and label the column — for columns, indexes and object columns alike.

Timestamps from APIs, databases and log files usually arrive timezone-aware — pandas knows they are in UTC or in Europe/Berlin. Write such a frame to Excel and pandas stops:

Text
ValueError: Excel does not support datetimes with timezones. Please ensure that datetimes are timezone unaware before writing to Excel.

The message is accurate. An Excel date is a number of days since 1900 with no room for a zone, so any zone information would be lost silently — pandas refuses rather than guess which clock time you wanted. The fix is to make that decision explicitly. This guide, part of Troubleshooting Common Python Excel Errors, shows how, including the places the error hides after the obvious column is fixed.

Converting UTC timestamps to the reader's local time before writing Before, the timestamps are timezone-aware in UTC, 00:30 and 01:30 on 29 March 2026. Converting to Europe/London and dropping the zone gives 00:30 and 02:30 local time, because British clocks moved forward that night, and these naive values write to Excel without error. A 1 ts 2 2026-03-29 00:30+00:00 3 2026-03-29 01:30+00:00 A 1 ts (London) 2 2026-03-29 00:30 3 2026-03-29 02:30 aware, UTC naive, Europe/London tz_convert + tz_localize(None) the clocks went forward that night — converting through the zone gets the local times right

Prerequisites

Bash
pip install pandas openpyxl
Python
import pandas as pd

events = pd.DataFrame({
    "event": ["deploy", "incident"],
    "ts": pd.to_datetime(["2026-03-29 00:30", "2026-03-29 01:30"]).tz_localize("UTC"),
})
try:
    events.to_excel("events.xlsx", index=False)
except ValueError as exc:
    print(exc)

Step 1 — Decide which clock the reader needs

Before touching code, answer one question: when a reader looks at the cell, which time should they see? There are two sensible answers, and they lead to different fixes:

  • Local time — for people in one place reading about their own day: shift schedules, delivery times, anything tied to office hours.
  • UTC — for systems, logs and anything compared across regions, where one consistent clock matters more than familiarity.
Choosing which time to write to Excel If the readers are in one time zone and think in local time, convert to that zone and drop it. If the data spans regions or feeds other systems, convert to UTC and drop the zone, labelling the column as UTC. If readers need both, write two columns. Who reads the timestamps? people in one place Local time tz_convert(zone), then drop systems or many regions UTC label the header (UTC) both Two columns local and UTC

Whichever you choose, say so in the header. A cell reading 02:30 means nothing trustworthy unless the column is called Time (London) or Time (UTC). The header is the only place the zone survives once the file leaves Python, so treat it as part of the data, not decoration.

Step 2 — Convert, then drop the zone

For local time, convert to the zone first, then remove the zone with tz_localize(None), which keeps the converted wall-clock time:

Python
local = events.copy()
local["ts"] = local["ts"].dt.tz_convert("Europe/London").dt.tz_localize(None)
local = local.rename(columns={"ts": "Time (London)"})
local.to_excel("events_local.xlsx", index=False)
print(local)

For UTC, tz_convert(None) converts to UTC and drops the zone in one step:

Python
utc = events.assign(ts=events["ts"].dt.tz_convert(None)).rename(columns={"ts": "Time (UTC)"})
utc.to_excel("events_utc.xlsx", index=False)

The two look similar and are easy to confuse. tz_localize(None) on a UTC column keeps the UTC clock time; on a London column it keeps the London time. tz_convert(None) always produces UTC. Use the pattern convert to the zone you want, then localize to None, and there is nothing to remember.

Step 3 — Fix every aware column at once

Real frames often have several timestamp columns. Select them all by dtype and convert them in one pass, so a new column added next month does not reintroduce the error:

Python
def naive_in(frame: pd.DataFrame, zone: str) -> pd.DataFrame:
    out = frame.copy()
    for col in out.select_dtypes(include=["datetimetz"]).columns:
        out[col] = out[col].dt.tz_convert(zone).dt.tz_localize(None)
    return out

events["acknowledged"] = events["ts"] + pd.Timedelta(minutes=12)
naive_in(events, "Europe/London").to_excel("events_all.xlsx", index=False)
print(pd.read_excel("events_all.xlsx").dtypes.to_dict())

The read-back shows ordinary datetime64 columns — Excel dates with no zone, exactly what the file can hold.

Step 4 — Check the index and object columns

If the error survives Step 3, it is hiding in one of two places. A timezone-aware index — common after set_index("ts") or resampling — is written with the data unless you pass index=False, and the column helper does not touch it. Convert the columns and the index separately:

Python
by_time = naive_in(events.set_index("ts"), "Europe/London")   # the remaining columns
by_time.index = by_time.index.tz_convert("Europe/London").tz_localize(None)
by_time.to_excel("events_indexed.xlsx")

The other hiding place is an object column that holds individual Timestamp values with zones — typical when data came from JSON or a list of Python objects. select_dtypes(include="datetimetz") does not see those, because the column's dtype is object. Convert first with pd.to_datetime(col, utc=True), which produces a proper UTC column, and then apply the conversion above.

Where timezone-aware datetimes hide in a DataFrame The error can come from datetime columns with a time zone dtype, from a timezone-aware index, or from object columns that hold individual zoned Timestamp values. Each needs converting before to_excel. datetime64[ns, UTC] columns select_dtypes(include='datetimetz') A timezone-aware index df.index.tz is not None Object columns of Timestamps pd.to_datetime(col, utc=True) first

Step 5 — Keep the zone where the reader can see it

Stripping the zone from the cells does not mean losing it. Put it in the column header, as above, or add a small text column when rows come from several zones — for example, a report of deliveries across regions, each shown in its own local time:

Python
deliveries = pd.DataFrame({
    "region": ["London", "New York"],
    "zone": ["Europe/London", "America/New_York"],
    "ts": pd.to_datetime(["2026-06-01 14:00", "2026-06-01 14:00"]).tz_localize("UTC"),
})
deliveries["local_time"] = [ts.tz_convert(z).tz_localize(None) for ts, z in zip(deliveries["ts"], deliveries["zone"])]
deliveries["utc_offset"] = [ts.tz_convert(z).strftime("%z") for ts, z in zip(deliveries["ts"], deliveries["zone"])]
deliveries.drop(columns="ts").to_excel("deliveries.xlsx", index=False)
print(deliveries[["region", "local_time", "utc_offset"]])

The same instant appears as 15:00 in London (+0100, summer time) and 10:00 in New York (-0400). With the offset in its own column, a reader can compare rows correctly even though each shows a local clock.

Step 6 — Read the times back into aware timestamps

The reverse trip needs the same care. A workbook column labelled Time (London) reads back as naive datetimes; to compare it with UTC data again, attach the zone it was written in. Local clocks are awkward twice a year: in spring an hour does not exist, in autumn an hour happens twice. tz_localize refuses to guess about either unless you tell it how:

Python
back = pd.read_excel("events_local.xlsx")
times = back["Time (London)"]
try:
    times.dt.tz_localize("Europe/London")
    print("localised without trouble")
except Exception as exc:
    print(type(exc).__name__)

odd = pd.Series(pd.to_datetime(["2026-03-29 01:30", "2026-10-25 01:30"]))
aware = odd.dt.tz_localize("Europe/London", nonexistent="shift_forward", ambiguous="NaT")
print(aware.tolist())

The values written in Step 2 localise cleanly, because they came from real instants. The second series shows the edge cases: 01:30 on 29 March 2026 never happened in London, so nonexistent="shift_forward" moves it to 02:00; 01:30 on 25 October happened twice, so ambiguous="NaT" marks it missing rather than picking one. Choosing those policies explicitly — and reporting any NaT they produce — is safer than letting a default hide an hour of data. If readers edit timestamps by hand in the workbook, expect these cases to appear. Keeping a UTC column alongside the local one, as Step 1 suggested for mixed audiences, avoids the question entirely when the file comes back.

Writing with openpyxl directly

openpyxl enforces the same rule with its own message when you assign a zoned datetime to a cell:

Python
from datetime import datetime
from zoneinfo import ZoneInfo

from openpyxl import Workbook

wb = Workbook()
try:
    wb.active["A1"] = datetime(2026, 3, 29, 1, 30, tzinfo=ZoneInfo("UTC"))
    wb.save("direct.xlsx")
except TypeError as exc:
    print(exc)
wb.active["A1"] = datetime(2026, 3, 29, 1, 30, tzinfo=ZoneInfo("UTC")).astimezone(ZoneInfo("Europe/London")).replace(tzinfo=None)
wb.save("direct.xlsx")

The fix is the same: convert to the wanted zone, then remove tzinfo with replace(tzinfo=None). Formatting the resulting dates for display is covered in Format Dates in Excel Cells with Python.

Common pitfalls

  • Dropping the zone without converting. tz_localize(None) on a UTC column writes UTC times; readers in another zone will read them as local. Convert first.
  • Daylight saving. Local times skip or repeat an hour on changeover nights. Converting through the zone handles this correctly; adding a fixed offset does not.
  • Reading the file back and re-localising. A naive local time read from Excel must be localised with the zone it was written in — tz_localize("Europe/London") — before any further conversion.
  • Mixed zones in one column. A column built from several sources can contain timestamps in different zones, which pandas stores as object. pd.to_datetime(col, utc=True) normalises them to one UTC column before any conversion; skipping that step leaves the .dt accessor unavailable and the error in place.
  • Unlabelled columns. Without "(UTC)" or a zone in the header, nobody can tell which clock a column uses a month later.

Conclusion

Excel dates cannot carry a time zone, so pandas refuses to write aware timestamps. Decide which clock readers need, convert each aware column, index and object column to that zone, drop the zone with tz_localize(None), and record the zone in the header or an offset column. Use the same convert-then-strip rule when writing cells with openpyxl.

Frequently asked questions

What is the quickest fix? Convert each timezone-aware column to the time zone your readers expect and then drop the zone — call .dt.tz_convert("Europe/London") on the column, then .dt.tz_localize(None). The values then write as ordinary Excel dates.

Should I use tz_localize(None) or tz_convert(None)? Both remove the zone but give different clock times. tz_convert(None) converts to UTC first and then drops the zone; tz_localize(None) keeps the local wall-clock time of the current zone. Choose tz_convert to the zone you want, then tz_localize(None).

Why do I still get the error after fixing all columns? The index can be timezone-aware too, and so can an object column holding Timestamp values with a zone. Check df.index and convert object columns with pd.to_datetime before stripping the zone.

How do I keep the time zone information for the reader? Put it in the header — "Created (UTC)" or "Created (Europe/London)" — or add a text column with the UTC offset. The cells themselves cannot hold it.

More on errors & fixes

More on errors & fixes →