Fix Excel Dates Showing as Numbers in pandas
The sheet shows dates. pandas returns integers:
>>> frame = pd.read_excel("data/orders.xlsx")
>>> frame["booked_on"].head(3)
0 46218
1 46219
2 46220
Name: booked_on, dtype: int64
Converting with a naive epoch produces something that looks plausible and is one day wrong:
>>> pd.to_datetime(frame["booked_on"], unit="D", origin="1900-01-01")
0 2026-07-16 # the sheet says 15 July
Both symptoms come from the same source: the cell stores a day serial, and the mapping from serial to calendar date is not the obvious one.
Root Cause
Excel stores a date as the number of days since an epoch, and pairs it with a number format that decides how it is drawn. When the format is present, pandas and openpyxl reconstruct a datetime. When the format has been stripped — by a CSV round trip, a paste-as-values, or an export from a reporting tool — the same number arrives as a bare integer with nothing to say it is a date.
The one-day error has a separate cause. Excel treats 1900 as a leap year, reproducing a Lotus 1-2-3 bug for compatibility, so its calendar contains a 29 February 1900 that never existed. Serial 1 is 1 January 1900, but every serial above 60 is shifted one day later than a strict count would give. Using 1899-12-30 as the origin cancels the phantom day exactly.
Minimal Diagnostic
Check whether the column holds serials, datetimes or text before converting anything, and read the workbook's date system rather than assuming it.
# pip install "pandas>=2.2,<3" "openpyxl>=3.1,<4"
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook
XLSX = Path("data/orders.xlsx")
def date_column_profile(xlsx_path: Path, column: str, sheet=0) -> dict:
"""Report the dtype, a sample value and the workbook's epoch."""
if not xlsx_path.exists():
raise FileNotFoundError(f"No such workbook: {xlsx_path}")
try:
frame = pd.read_excel(xlsx_path, sheet_name=sheet)
except Exception as exc:
raise RuntimeError(f"Could not read {xlsx_path}: {exc}") from exc
if column not in frame.columns:
raise KeyError(f"No column {column!r}; found {list(frame.columns)[:8]}")
book = load_workbook(xlsx_path, read_only=True)
epoch_year = book.epoch.year
book.close()
series = frame[column]
return {
"dtype": str(series.dtype),
"sample": series.dropna().head(3).tolist(),
"looks_like_serial": pd.api.types.is_numeric_dtype(series)
and series.dropna().between(20000, 60000).all(),
"workbook_epoch": epoch_year,
"origin_to_use": "1904-01-01" if epoch_year == 1904 else "1899-12-30",
}
if __name__ == "__main__":
print(date_column_profile(XLSX, "booked_on"))
{'dtype': 'int64', 'sample': [46218, 46219, 46220], 'looks_like_serial': True,
'workbook_epoch': 1899, 'origin_to_use': '1899-12-30'}
The between(20000, 60000) heuristic is a sanity band, not a proof: serial 20000 is 1954 and 60000 is 2064, which brackets essentially all business data. A numeric column outside that range is more likely a quantity than a date.
The Fix
# pip install "pandas>=2.2,<3"
import pandas as pd
EXCEL_EPOCH = "1899-12-30" # cancels the phantom 29 Feb 1900
def serial_to_datetime(series: pd.Series, origin: str = EXCEL_EPOCH) -> pd.Series:
"""Convert Excel day serials to datetimes; unconvertible values become NaT."""
numeric = pd.to_numeric(series, errors="coerce") # tolerate stray text rows
return pd.to_datetime(numeric, unit="D", origin=origin, errors="coerce")
if __name__ == "__main__":
frame = pd.read_excel("data/orders.xlsx")
frame["booked_on"] = serial_to_datetime(frame["booked_on"])
print(frame[["booked_on"]].head())
print(frame["booked_on"].dtype) # datetime64[ns]
Three details in those four lines. pd.to_numeric(..., errors="coerce") runs first so a stray "N/A" becomes NaN instead of raising. unit="D" treats the number as days, which means a fractional serial keeps its time of day rather than being truncated. And errors="coerce" on the datetime conversion turns an out-of-range serial into NaT rather than aborting the whole column.
Variant Fix 1: The Format Is Present but pandas Still Returns Numbers
If openpyxl reports a date format yet pandas gives integers, the column is mixed: some cells are real dates and some are numbers pasted as values. read_excel cannot promote to datetime64 while integers are present, so it falls back.
# pip install "pandas>=2.2,<3"
import pandas as pd
def normalise_mixed(series: pd.Series, origin: str = "1899-12-30") -> pd.Series:
"""Combine already-parsed datetimes with rows that are still serials."""
result = pd.to_datetime(series, errors="coerce") # real datetimes convert here
still_missing = result.isna() & series.notna()
numeric = pd.to_numeric(series[still_missing], errors="coerce")
result.loc[still_missing] = pd.to_datetime(
numeric, unit="D", origin=origin, errors="coerce"
)
return result
if __name__ == "__main__":
mixed = pd.Series([pd.Timestamp("2026-07-15"), 46219, "2026-07-17", None])
print(normalise_mixed(mixed))
Run the datetime pass first. Doing the serial pass first would convert a genuine year like 2026 — stored as a number in some exports — into 1905, which is exactly the kind of silent corruption that survives review.
Variant Fix 2: The 1904 Date System
Workbooks created by older Excel for Mac count from 1 January 1904. Every date read with the 1900 origin lands 1,462 days early, which is four years and one day — an error big enough to notice and small enough to be dismissed as a data problem.
# pip install "openpyxl>=3.1,<4" "pandas>=2.2,<3"
from openpyxl import load_workbook
import pandas as pd
def origin_for(xlsx_path: str) -> str:
"""Read the date system from the workbook rather than assuming 1900."""
book = load_workbook(xlsx_path, read_only=True)
try:
return "1904-01-01" if book.epoch.year == 1904 else "1899-12-30"
finally:
book.close()
if __name__ == "__main__":
path = "data/mac_export.xlsx"
frame = pd.read_excel(path)
frame["booked_on"] = pd.to_datetime(
pd.to_numeric(frame["booked_on"], errors="coerce"),
unit="D", origin=origin_for(path), errors="coerce",
)
Detecting rather than assuming costs one function call and removes an entire class of "the dates are four years out" incident from batch jobs that ingest workbooks from many sources.
Variant Fix 3: Times, Durations and Fractions
A serial's fractional part is the fraction of a day. 0.5 is noon; a cell formatted as [h]:mm holding 1.25 is 30 hours, not 1.25 hours.
# pip install "pandas>=2.2,<3"
import pandas as pd
serials = pd.Series([46218.0, 46218.5, 46218.75])
timestamps = pd.to_datetime(serials, unit="D", origin="1899-12-30")
print(timestamps)
# 2026-07-15 00:00:00 / 12:00:00 / 18:00:00
durations = pd.to_timedelta(pd.Series([0.5, 1.25]), unit="D")
print(durations) # 0 days 12:00:00 / 1 days 06:00:00
Rounding a serial before conversion throws the time away silently. If the source has times, convert first and truncate afterwards with .dt.normalize() when you genuinely want dates only.
Verification
A wrong origin does not produce errors — it produces plausible dates. Assert on the range, on the null share, and where possible on a value you can check by eye against the sheet.
# pip install "pandas>=2.2,<3"
import pandas as pd
def assert_dates_correct(frame: pd.DataFrame, column: str,
known: dict[int, str] | None = None) -> None:
"""Fail on non-datetime dtype, heavy NaT, implausible range or a wrong known value."""
series = frame[column]
assert pd.api.types.is_datetime64_any_dtype(series), f"{column}: dtype is {series.dtype}"
null_pct = series.isna().mean() * 100
assert null_pct < 5, f"{column}: {null_pct:.1f}% NaT — wrong origin or mixed formats"
low, high = pd.Timestamp("2000-01-01"), pd.Timestamp("2100-01-01")
outside = series[(series < low) | (series > high)].dropna()
assert outside.empty, f"{column}: {len(outside)} implausible date(s), e.g. {outside.iloc[0].date()}"
for row_index, expected in (known or {}).items():
actual = series.iloc[row_index]
assert str(actual.date()) == expected, (
f"row {row_index}: expected {expected}, got {actual.date()} — origin is off"
)
print(f"{column}: {series.notna().sum():,} date(s) verified")
if __name__ == "__main__":
df = pd.read_excel("data/orders.xlsx")
df["booked_on"] = pd.to_datetime(pd.to_numeric(df["booked_on"], errors="coerce"),
unit="D", origin="1899-12-30", errors="coerce")
assert_dates_correct(df, "booked_on", known={0: "2026-07-15"})
The known argument is the one that catches an off-by-one or an off-by-four-years: open the sheet once, read the first date, and pin it in the test. Everything else in this class of bug hides behind values that look reasonable. For the broader treatment of formats, timezones and writing dates back out, see working with Excel dates and number formats.
FAQ
Why 1899-12-30 rather than 1900-01-01? Because Excel's calendar contains a 29 February 1900 that does not exist. Moving the origin back two days from 1 January 1900 compensates for both the phantom day and the fact that Excel counts from serial 1 rather than 0.
Is xlrd responsible for this?
No. xlrd no longer reads .xlsx at all — that error is covered in fix xlrd error reading xlsx files. Serial values come from the workbook, whichever engine reads it.
Why do some rows convert and others do not?
The column is mixed. Convert real datetimes first, then treat only the remaining numeric values as serials, as normalise_mixed above does.
Can I stop this happening at the source?
Yes — ask for the export in ISO text (2026-07-15) or as CSV with a documented date format. A date that survives as text with a known format is more portable than one that depends on a number format being preserved.
Do I need to worry about the 1904 system in practice?
Rarely, but the check is one line and the failure is a silent four-year shift. Read book.epoch in any job that accepts workbooks from outside your own team.
When the Column Is Not a Date at All
A numeric column in the plausible serial range is not proof of anything. Quantities, reference numbers and prices all land between twenty and sixty thousand, and converting one of them produces a column of confident, meaningless dates that nothing downstream will question.
Use the column's name and its neighbours as the tie-break, and where both are ambiguous, convert one value and check it against the source workbook by eye. Thirty seconds of confirmation is considerably cheaper than a report built on quantities that were silently reinterpreted as days.
Related
- Working with Excel Dates and Number Formats — the full treatment of values against formats
- Fix openpyxl Number Format Not Applied — the write-side version of this problem
- Reading Excel Files with Python — engines, sheets and dtype control
- Fix xlrd Error Reading xlsx Files — engine selection for modern workbooks
- Cleaning Messy CSV Data with pandas — coercion patterns for mixed columns