EduFlow

Section 3 · Cleaning Real Data

Parsing Messy Dates

20 min3 min readPython exercise

Lesson at a glance

Parse a date column that mixes ISO, day-first and month-first formats with explicit to_datetime formats, flag the ambiguous values, and verify the result against a trusted source.

  1. 1Letting pandas guess
  2. 2Parse each format explicitly
  3. 3Flag the ambiguity
  4. 4Verify against the source of truth
  5. 5What a real date column gives you

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The messy export writes dates three ways: 2024-03-05, 05/03/2024 and 03/05/2024. The first is unambiguous. The other two are the same day written by a European and an American spreadsheet, and the trouble is that 03/05/2024 might also be the European way of writing 3 May. Dates are the column where “it parsed without errors” and “it is correct” are furthest apart.

Letting pandas guess

Python
import pandas as pd

messy = pd.read_csv("orders_messy.csv")
print(messy["order_date"].sample(8, random_state=1).tolist())
try:
    pd.to_datetime(messy["order_date"])
except ValueError as error:
    print(str(error).splitlines()[0])

pandas 2 infers one format from the first value, 2024-01-02, and applies it to the whole column. Row 6, 08/04/2024, does not fit, so the conversion stops. That strictness is a feature: older versions guessed row by row and could silently mix up days and months.

Parse each format explicitly

You know which formats exist, so say so. Format codes describe the layout: %Y is a four-digit year, %m a two-digit month, %d a two-digit day. Parse the column once per format with errors="coerce", which turns non-matching rows into NaT (pandas’ “not a time”), then fill the gaps in order of preference:

Python
import pandas as pd

messy = pd.read_csv("orders_messy.csv")
raw = messy["order_date"]
iso = pd.to_datetime(raw, format="%Y-%m-%d", errors="coerce")
dmy = pd.to_datetime(raw, format="%d/%m/%Y", errors="coerce")
mdy = pd.to_datetime(raw, format="%m/%d/%Y", errors="coerce")
print(iso.notna().sum(), "ISO |", dmy.notna().sum(), "fit day-first |", mdy.notna().sum(), "fit month-first")

parsed = iso.fillna(dmy).fillna(mdy)
print(parsed.isna().sum(), "unparsed")
print(parsed.min(), parsed.max())

Every row parsed, and the range is right: January 2024 to December 2025. Day-first comes before month-first in the chain because most of Cartwheel’s countries write dates that way. pandas can do the same in one call with pd.to_datetime(raw, format="mixed", dayfirst=True), which parses each value on its own; the explicit version is longer but shows you exactly which rule decided each row.

But look at the counts. 57 values fit day-first and 44 fit month-first, from only 68 slashed values. So 33 of them fit both.

Three boxes. 25/10/2025 can only be day-first because there is no month 25. 10/25/2025 can only be month-first. 05/08/2025 fits both readings, 5 August or 8 May, so it is ambiguous and needs outside information. 25/10/2025 no month 25 day-first only 10/25/2025 no month 25 month-first only 05/08/2025 5 Aug or 8 May? ambiguous the parser can decide these only outside data can decide
Three kinds of slashed date

Flag the ambiguity

A value that parses both ways to different dates is ambiguous. Three of the 33 are dates like 05/05/2025, where both readings agree, so they are harmless:

Python
import pandas as pd

messy = pd.read_csv("orders_messy.csv")
raw = messy["order_date"]
dmy = pd.to_datetime(raw, format="%d/%m/%Y", errors="coerce")
mdy = pd.to_datetime(raw, format="%m/%d/%Y", errors="coerce")
ambiguous = dmy.notna() & mdy.notna() & (dmy != mdy)
print(ambiguous.sum(), "ambiguous dates")
print(raw[ambiguous].head(6).tolist())

Thirty rows depend on an assumption. You can make the assumption, as the fillna chain did, but you should know you made it, and the person reading your report should too.

Verify against the source of truth

Cartwheel’s clean orders table is the system of record: every order id in the export also appears there with its real timestamp. A lookup by order id lets you test the assumption directly. set_index("order_id") turns the dates into a Series labelled by id, and map() looks each export row up in it:

Python
import pandas as pd

messy = pd.read_csv("orders_messy.csv")
raw = messy["order_date"]
iso = pd.to_datetime(raw, format="%Y-%m-%d", errors="coerce")
dmy = pd.to_datetime(raw, format="%d/%m/%Y", errors="coerce")
mdy = pd.to_datetime(raw, format="%m/%d/%Y", errors="coerce")
parsed = iso.fillna(dmy).fillna(mdy)

orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
true_dates = orders.set_index("order_id")["order_date"].dt.normalize()
truth = messy["order_id"].map(true_dates)

wrong = parsed != truth
print(wrong.sum(), "dates disagree with the order system")
print(messy.loc[wrong, ["order_id", "order_date"]].assign(parsed=parsed[wrong].dt.date, real=truth[wrong].dt.date))

dt.normalize() drops the time of day so the comparison is date to date. Five dates are wrong, and every one is an ambiguous value that was really month-first: 02/08/2025 is 8 February, not 2 August. The day-first assumption was right for 25 of 30 ambiguous rows, which sounds good until you realise it moves five orders into the wrong month, and some into the wrong quarter.

Times and time zones

The export carries dates only, but the orders table has full timestamps such as 2025-12-30 21:19:03, and none of them say which time zone they are in. Cartwheel records everything in one store clock, so you can treat them as local times and move on. When a file does include offsets like +04:00, pass utc=True to pd.to_datetime so every value lands on one common clock before you compare or group them; leaving zones mixed is how two orders placed at the same moment in Dubai and London end up on different dates, or even in different years around midnight on 31 December.

What a real date column gives you

Once the column is datetime64, the .dt accessor exposes its parts:

Python
import pandas as pd

orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
d = orders["order_date"]
print(d.dt.year.value_counts().sort_index())
print(d.dt.day_name().value_counts().head(3))
print(d.dt.to_period("M").value_counts().sort_index().tail(3))

Year, month, weekday name, calendar month as a period: these are the keys you will group by in Section 4, where Lesson 4.4 covers time series properly.

In the exercise you parse, flag and repair the export’s dates. Then one problem remains before the export is clean: rows that appear twice.

Key takeaways

  • Without format=, pandas 2 guesses the format from the first value and fails on any row written differently.
  • Parse each known format explicitly with pd.to_datetime(col, format=..., errors="coerce"), then combine the results with fillna.
  • A date like 05/08/2025 parses under both day-first and month-first rules; no parser can tell you which one is true.
  • Flag ambiguous values and resolve them from a trusted source (the system of record, another column, the person who made the file), never by assumption alone.
  • Once a column is datetime64, the .dt accessor gives you year, month, day_name() and to_period() for grouping.

Further reading

Check your understanding

3 questions

Question 1 of 3

pd.to_datetime(messy["order_date"]) raises ValueError: time data "08/04/2024" doesn't match format "%Y-%m-%d". Why?

Question 2 of 3

Which of these values is ambiguous, meaning both day-first and month-first readings give a valid but different date?

Question 3 of 3

Your parser assumes day-first for ambiguous dates. How should you check whether that assumption is right?

Practice

Python exercise

Parse, flag and repair the dates

Using raw = messy["order_date"]: build parsed by parsing ISO (%Y-%m-%d), then day-first (%d/%m/%Y), then month-first (%m/%d/%Y) and combining them in that order. Add a boolean column is_ambiguous that is True where the day-first and month-first parses both succeed and give different dates. Set truth to each row’s real date from orders, looked up by order_id with the time removed, and n_wrong to how many parsed dates disagree with it. Finally, set messy["order_date"] to parsed, except on ambiguous rows, where it takes the value from truth.

Data files: orders_messy.csv, orders.csv

import pandas as pd

messy = pd.read_csv("orders_messy.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
raw = messy["order_date"]

# 1. Parse raw three ways (ISO, day-first, month-first) and combine: ISO, then day-first, then month-first
parsed = None

# 2. Column is_ambiguous: True where raw parses both day-first and month-first to DIFFERENT dates

# 3. The real date of each export row, looked up by order_id in orders (date only, no time)
truth = None

# 4. How many parsed dates disagree with truth
n_wrong = None

# 5. messy["order_date"]: parsed dates, but use truth for the ambiguous rows
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • parsed is a datetime Series with no unparsed values
  • is_ambiguous flags 30 rows
  • n_wrong is 5
  • Order 2401 is dated 8 February 2025, not 2 August
  • Every repaired date matches the order system