Convert Dates Like 20240131, 31.01.24 and 2024031 with pandas
Dates that come out of accounting systems, mainframes and older ERPs rarely arrive as dates. They arrive as 20240131 — eight digits that Excel and pandas both read as the number twenty million — or as text like 31.01.24, 31-Jan-24, or 2024031, a year followed by the day of the year. Mixed in are placeholders such as 00000000 for "no date" and the occasional impossible value like 20240231. pd.to_datetime can parse all of these, but only when told the layout; letting it guess invites day-month swaps and silent failures. This page, part of Working with Dates and Times in Excel Data, converts each layout explicitly and reports what did not convert.
Prerequisites
pip install pandas openpyxl
postings.xlsx has six documents with dates in four layouts: an eight-digit number, dd.mm.yy text, dd-Mon-yy text and a day-of-year code. The data includes a leap day, an impossible 31 February, a 0 placeholder, blanks and a two-digit year of 69.
Step 1 — See what read_excel returns
import pandas as pd
raw = pd.read_excel("postings.xlsx", dtype={"value_date": str, "entered": str, "julian": str})
print(raw.dtypes.to_dict())
print(raw)
posting is int64: nothing about 20240131 tells pandas it is a date. The text columns are read as strings because of the dtype argument — without it, julian would also be read as an integer. Blank cells arrive as NaN either way. Reading date-like codes as text is a good default: it keeps leading zeros and stops pandas guessing.
Step 2 — Parse yyyymmdd numbers with an explicit format
Convert the integers to strings and parse them with format="%Y%m%d". Placeholders and impossible dates will not match, so use errors="coerce" to turn them into NaT — and look at them immediately:
posting_text = raw["posting"].astype(str).str.zfill(8)
posting = pd.to_datetime(posting_text, format="%Y%m%d", errors="coerce")
print(pd.DataFrame({"raw": raw["posting"], "parsed": posting}))
20240229 parses — 2024 is a leap year — and 20240231 becomes NaT, because there is no 31 February. The 0 placeholder, padded to 00000000 by zfill, is also NaT. Those are two different situations — an invalid date that someone should fix, and a deliberate "no date" — and the next step keeps them apart.
Step 3 — Separate placeholders from invalid dates
Decide which values mean "no date" in this export — commonly 0, 00000000, 99991231 or blank — and treat them as missing on purpose. Anything else that fails to parse is an error to report:
PLACEHOLDERS = {"00000000", "99991231", ""}
def parse_dates(text, fmt):
text = text.fillna("").astype(str).str.strip()
is_placeholder = text.isin(PLACEHOLDERS)
parsed = pd.to_datetime(text.where(~is_placeholder), format=fmt, errors="coerce")
invalid = parsed.isna() & ~is_placeholder
return parsed, invalid
posting, bad_posting = parse_dates(posting_text, "%Y%m%d")
print("invalid posting dates:", raw.loc[bad_posting, ["doc", "posting"]].to_dict("records"))
Only D3's 20240231 is reported. D4's placeholder is a missing date, as the export intended. 99991231 — "valid forever" — is another common placeholder in master data; whether it should become missing or stay as a real far-future date depends on what the column means.
Step 4 — Parse the text layouts
Each remaining column gets its own format. %d.%m.%y and %d-%b-%y use two-digit years; %Y%j reads a four-digit year followed by the day of the year:
value_date, bad_value = parse_dates(raw["value_date"], "%d.%m.%y")
entered, bad_entered = parse_dates(raw["entered"], "%d-%b-%y")
julian, bad_julian = parse_dates(raw["julian"], "%Y%j")
dates = pd.DataFrame({"doc": raw["doc"], "posting": posting, "value_date": value_date,
"entered": entered, "julian": julian})
print(dates.to_string(index=False))
print("invalid:", {c: int(m.sum()) for c, m in
{"value_date": bad_value, "entered": bad_entered, "julian": bad_julian}.items()})
Every layout produces the same calendar dates where the source agrees. %b matches English month abbreviations (Jan, Feb); exports in other languages — 31-Jän-24, 31-janv.-24 — need the month names mapped to numbers first. Day-of-year 060 in 2024 is 29 February and in 2025 would be 1 March, which %j gets right because it knows the year.
Dates with times attached
Log-style exports often append the time: 20240131143000 for 14:30 on 31 January, or a date column next to a separate HHMM time column. The same approach works — extend the format, or join the two columns into one string first:
stamps = pd.Series(["20240131143000", "20240229081505"])
print(pd.to_datetime(stamps, format="%Y%m%d%H%M%S").tolist())
split = pd.DataFrame({"date": [20240131, 20240229], "time": [930, 1745]})
joined = split["date"].astype(str) + split["time"].astype(str).str.zfill(4)
print(pd.to_datetime(joined, format="%Y%m%d%H%M").tolist())
zfill(4) matters for the second form. Times are exported as numbers, so 09:30 becomes 930 and 00:45 becomes 45. The parser is lenient about digit counts, which makes the unpadded forms dangerous rather than failing: 2024013145 parses as 04:05, and 202401315 — the time 00:05 — as 3 January at 01:05, with the wrong date. Padding every time to four digits makes the string unambiguous. Keep times in the same timezone as the source system; Handle Timezones in Excel Timestamps with Python covers converting them.
Step 5 — Check the two-digit year rule
D5's value date is 05.11.69. With %y, Python maps two-digit years 69–99 to 1969–1999 and 00–68 to 2000–2068:
print(dates.loc[4, ["doc", "value_date"]].to_dict())
The result is 5 November 1969, while the same document's other dates are in 2024 — almost certainly a typo for 05.11.24, or a system that meant 2069. The pivot is right for recent transaction dates and wrong for older ones: birth dates from the 1950s written as 52 would become 2052. When the rule does not fit, parse the two digits yourself and choose the century from context:
def century_from_context(text, cutoff_year=2030):
parts = text.str.extract(r"(?P<d>\d{2})\.(?P<m>\d{2})\.(?P<y>\d{2})").astype(float)
year = 2000 + parts["y"]
year = year.where(year <= cutoff_year, year - 100)
return pd.to_datetime(pd.DataFrame({"year": year, "month": parts["m"], "day": parts["d"]}), errors="coerce")
print(century_from_context(raw["value_date"]).tolist())
With a cutoff of 2030, 69 becomes 1969 as before but 25 stays 2025; a plausibility check — value dates more than a few years from the posting date — is what actually catches the typo. Validate Excel Columns Before Import with pandas shows how to express range checks like that.
Step 6 — Write real dates, and the report of failures
Write the converted dates with a date format, and the invalid values on their own sheet so they can be corrected in the source system:
invalid_rows = raw.loc[bad_posting | bad_value | bad_entered | bad_julian]
with pd.ExcelWriter("postings_clean.xlsx", engine="xlsxwriter",
datetime_format="yyyy-mm-dd", date_format="yyyy-mm-dd") as writer:
dates.to_excel(writer, sheet_name="Postings", index=False)
invalid_rows.to_excel(writer, sheet_name="Invalid dates", index=False)
print(pd.read_excel("postings_clean.xlsx", sheet_name=None)["Invalid dates"]["doc"].tolist())
The xlsxwriter engine applies datetime_format to every date cell, so the column shows 2024-01-31 rather than a time of midnight. Write Dates Without the Time Part from pandas to Excel explains why that option only works with xlsxwriter.
Common pitfalls
to_datetimeon the integer column. An integer is read as nanoseconds since 1970, not as yyyymmdd; convert to text and pass the format.format="mixed"or no format at all. It guesses per value and can swap day and month; state the format.errors="coerce"without a report. Invalid dates vanish intoNaT.- Treating placeholders as errors. Decide which codes mean "no date" and set them aside first.
Performance notes
Parsing with an explicit format uses a fast path and handles millions of values per second; format="mixed" parses each value separately and is many times slower. Converting integers with astype(str) is cheap. For huge columns of yyyymmdd integers, arithmetic — year = n // 10000, month = n // 100 % 100, day = n % 100, then pd.to_datetime on the three columns — avoids string handling altogether.
Conclusion
Read export date columns as text, set known placeholders such as 00000000 aside, and parse each layout with an explicit format — %Y%m%d for eight-digit dates, %d.%m.%y and %d-%b-%y for text, %Y%j for day-of-year codes — using errors="coerce" together with a report of what failed. Check two-digit years against context, and write real dates with a date format, with invalid values on their own sheet for correction at the source.
Frequently asked questions
Why does pandas read 20240131 as a number? Because it is one. A date written as eight digits looks like an integer to Excel and to pandas, so the column arrives as int64. Convert it to a string and parse it with format="%Y%m%d", or parse the digits arithmetically.
What does errors="coerce" do? It turns values that do not match the format into NaT instead of raising. Use it together with a report of the rows that became NaT, so invalid dates such as 20240231 are seen rather than silently dropped.
How does pandas interpret two-digit years like 69 or 24? With %y, Python maps 69-99 to 1969-1999 and 00-68 to 2000-2068. That is right for recent transaction dates and wrong for, say, birth dates in the 1950s. When the rule does not fit, parse the two digits yourself and add the right century.
What is a Julian date in an export? Usually a year followed by the day of the year, such as 2024031 for 31 January 2024. It parses with the format "%Y%j", where %j is the day of the year from 001 to 366.
Related
- Up one level: Working with Dates and Times in Excel Data — dates from import to report.
- Parse Excel Dates into Python datetimes with pandas — real Excel dates and mixed columns.
- Handle the 1904 Date System in Excel Files with Python — serial numbers from the other date system.
- Read European CSV Exports with Semicolons and Decimal Commas — day-first dates in CSV files.
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