Standardize mixed date formats in a CSV and list unparsed values
Convert several known date formats into ISO-style YYYY-MM-DD values while keeping the original CSV unchanged. Values that do not match an approved format or are not valid calendar dates are written to a separate review file.
Content checked 2026.09.20Hand-checked example
Show contents
Who this is forThis guide is for people who receive CSV files with several date formats and need a repeatable cleanup process that does not silently guess ambiguous values.
What you need
Python 3.12 and a terminal command that starts that version.
A text editor that can save UTF-8 CSV files.
A working folder where the script can create a new folder beneath outputs.
Only the Python standard library is required: csv, datetime, and pathlib.
01Define the accepted date formats before parsing
Date cleanup is safer when the accepted formats are explicit. This example recognizes six numeric formats and converts every successfully parsed value to YYYY-MM-DD. It does not use automatic date guessing.
Accepted input example
Meaning
Python format
2026-09-01
year-month-day
%Y-%m-%d
09/02/2026
month/day/year
%m/%d/%Y
2026/09/03
year/month/day
%Y/%m/%d
04-09-2026
day-month-year
%d-%m-%Y
2026.09.05
year.month.day
%Y.%m.%d
20260906
yearmonthday
%Y%m%d
The interpretation of slash and hyphen formats is a policy decision. In this tutorial, 09/02/2026 means September 2, 2026, while 04-09-2026 means September 4, 2026. If your source uses different conventions, change the accepted formats before processing real data.
02Create a small synthetic CSV
The following dataset is synthetic and was written specifically for this article. Save it as mixed_dates.csv in your working folder. It contains six valid dates in six different accepted formats and two values that should fail.
csv
record_id,event_date,note
R001,2026-09-01,ISO format
R002,09/02/2026,US slash format
R003,2026/09/03,year first with slashes
R004,04-09-2026,day first with hyphens
R005,2026.09.05,dot separated
R006,20260906,compact numeric
R007,2026-02-30,invalid calendar date
R008,Sep 7 2026,unsupported text format
There are 8 data records. The first 6 should parse successfully. R007 uses a recognized pattern but February 30 is not a real date. R008 uses a text month name that is intentionally not included in the accepted format list.
03Work out the expected cleaned values by hand
The first six values all represent consecutive dates from September 1 through September 6, 2026. They should therefore produce six standardized values. The remaining two records should keep their original text in the review file.
record_id
Original value
Expected standardized value
Status
R001
2026-09-01
2026-09-01
PARSED
R002
09/02/2026
2026-09-02
PARSED
R003
2026/09/03
2026-09-03
PARSED
R004
04-09-2026
2026-09-04
PARSED
R005
2026.09.05
2026-09-05
PARSED
R006
20260906
2026-09-06
PARSED
R007
2026-02-30
UNPARSED
R008
Sep 7 2026
UNPARSED
The expected counts are therefore 8 total records, 6 parsed records, and 2 unparsed records. The cleaned CSV will still contain all 8 records; unparsed rows receive an empty standardized_date value and an UNPARSED status.
04Parse approved formats and write new files
Save the following script as date_format_cleanup.py. It reads the original CSV, writes a complete cleaned copy, and also writes a smaller review CSV containing only the records whose date could not be standardized.
python
import csv
from datetime import datetime
from pathlib import Path
SOURCE = Path("mixed_dates.csv")
OUTPUT_DIR = Path("outputs") / "date_cleanup_result"
CLEANED = OUTPUT_DIR / "cleaned_dates.csv"
UNPARSED = OUTPUT_DIR / "unparsed_dates.csv"
DATE_COLUMN = "event_date"
FORMATS = (
"%Y-%m-%d",
"%m/%d/%Y",
"%Y/%m/%d",
"%d-%m-%Y",
"%Y.%m.%d",
"%Y%m%d",
)
def parse_date(value: str) -> str | None:
text = value.strip()
for format_string in FORMATS:
try:
parsed = datetime.strptime(text, format_string)
except ValueError:
continue
return parsed.strftime("%Y-%m-%d")
return None
def main() -> None:
if not SOURCE.is_file():
raise FileNotFoundError(f"Source CSV not found: {SOURCE}")
OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir() # Stop rather than reuse an existing result folder.
with SOURCE.open("r", encoding="utf-8-sig", newline="") as source:
reader = csv.DictReader(source)
if reader.fieldnames is None:
raise ValueError("CSV has no header row.")
if DATE_COLUMN not in reader.fieldnames:
raise ValueError(f"Missing required column: {DATE_COLUMN}")
if "standardized_date" in reader.fieldnames or "date_status" in reader.fieldnames:
raise ValueError("Output column name already exists in source CSV.")
cleaned_fields = [*reader.fieldnames, "standardized_date", "date_status"]
review_fields = ["row_number", *reader.fieldnames]
with CLEANED.open("x", encoding="utf-8", newline="") as cleaned_stream, \
UNPARSED.open("x", encoding="utf-8", newline="") as review_stream:
cleaned_writer = csv.DictWriter(cleaned_stream, fieldnames=cleaned_fields)
review_writer = csv.DictWriter(review_stream, fieldnames=review_fields)
cleaned_writer.writeheader()
review_writer.writeheader()
total = 0
parsed_count = 0
unparsed_count = 0
for row_number, row in enumerate(reader, start=2):
total += 1
original_value = row[DATE_COLUMN]
standardized = parse_date(original_value)
output_row = dict(row)
if standardized is None:
output_row["standardized_date"] = ""
output_row["date_status"] = "UNPARSED"
review_writer.writerow({"row_number": row_number, **row})
unparsed_count += 1
else:
output_row["standardized_date"] = standardized
output_row["date_status"] = "PARSED"
parsed_count += 1
cleaned_writer.writerow(output_row)
if parsed_count + unparsed_count != total:
raise RuntimeError("Record counts do not balance.")
print(f"Processed {total} records.")
print(f"Parsed: {parsed_count}; unparsed: {unparsed_count}.")
print(f"Cleaned CSV: {CLEANED.as_posix()}")
print(f"Review CSV: {UNPARSED.as_posix()}")
if __name__ == "__main__":
main()
text
python date_format_cleanup.py
05Compare the expected output files
cleaned_dates.csv should contain the original three columns plus standardized_date and date_status. All 8 original records remain present. The two failed rows are not deleted.
record_id
event_date
standardized_date
date_status
R001
2026-09-01
2026-09-01
PARSED
R002
09/02/2026
2026-09-02
PARSED
R003
2026/09/03
2026-09-03
PARSED
R004
04-09-2026
2026-09-04
PARSED
R005
2026.09.05
2026-09-05
PARSED
R006
20260906
2026-09-06
PARSED
R007
2026-02-30
UNPARSED
R008
Sep 7 2026
UNPARSED
unparsed_dates.csv should contain exactly 2 data rows. Because the original CSV header is physical line 1, R007 is CSV row 8 and R008 is CSV row 9.
row_number
record_id
event_date
8
R007
2026-02-30
9
R008
Sep 7 2026
06Check counts and console output
The expected console output below is derived by hand from the synthetic input and script. It is not a captured execution log.
Confirm that cleaned_dates.csv contains 8 data rows rather than only the 6 successful rows.
Confirm that standardized dates run from 2026-09-01 through 2026-09-06 for R001 through R006.
Confirm that R007 and R008 have empty standardized_date values and UNPARSED status.
Confirm that unparsed_dates.csv contains exactly R007 and R008.
Run the script again without changing OUTPUT_DIR. It should stop with FileExistsError instead of overwriting the previous result.
07Recognize common errors
Symptom
What to check
FileNotFoundError
Check SOURCE and the terminal's current working directory.
Missing required column
Confirm that the header contains event_date exactly, or change DATE_COLUMN deliberately.
Too many unparsed values
Inspect the source conventions and add only formats that are documented and unambiguous for that dataset.
Unexpected month and day reversal
Check whether slash or hyphen dates are month-first or day-first before adding their format.
FileExistsError
The result folder already exists. Review it and select a fresh output destination instead of overwriting it.
Valid-looking date still fails
The characters may contain spaces or a format not listed in FORMATS, or the calendar date itself may be invalid.
Do not respond to a high failure rate by adding many speculative formats. That can turn detectable bad data into silently misinterpreted dates.
08Understand the limits before scaling
This example handles calendar dates only. It does not parse times, time zones, Excel serial dates, localized month names, two-digit years, or values that contain additional descriptive text. Those cases need explicit rules.
datetime.strptime validates calendar dates, so a structurally plausible value such as 2026-02-30 is rejected. That is useful, but a successfully parsed date is not proof that the source value is factually correct. A typo such as 2026-09-12 instead of 2026-09-21 can still be a valid calendar date.
The order of FORMATS matters when two accepted patterns can interpret the same string differently. A production cleanup process should document the source convention, preserve the original value, report failures, and review ambiguous formats rather than relying on parsing alone.
Execution and verification record
2026-09-20 · hand-checked example · target: Python 3.12 · standard library: csv, datetime, pathlib · no execution
Manually counted 8 source records, with 6 expected successful parses and 2 expected failures.
Manually converted the six accepted inputs to consecutive standardized dates from 2026-09-01 through 2026-09-06.
Confirmed by calendar reasoning that 2026-02-30 is invalid and that Sep 7 2026 does not match any format listed in FORMATS.
Manually derived review row numbers 8 and 9 from the source CSV line positions.
Inspected the code to confirm that the original CSV is read only, results are written to a separate folder, and an existing result folder causes the run to stop.
Derived the expected processed, parsed, and unparsed counts and console messages by hand.
Verification limits
The code was not executed by the author of this response; no CSV output files were created.
Locale-dependent month names, time values, time zones, Excel serial dates, two-digit years, and very large files were not tested.
The accepted date conventions are example policies and must be changed if the real source uses different meanings.
The official documentation URLs were provided from known documentation locations but were not checked live.
Merge worksheets that use the same columns into one Excel table while recording which sheet each row came from. A small synthetic workbook lets you verify the row order, counts, and totals by hand.
Merge several PDFs into one file, extract an inclusive page range into a second file, and verify the resulting page counts and order. A synthetic set of blank pages with deliberately different dimensions makes the result checkable by hand.
Summarize order-level CSV data by month with pandas, then reconcile the grouped counts, units, and amounts against the source. A small synthetic example makes every expected result easy to verify by hand.
Find rows with exact matches in all four columns and collect them in a new Excel file with their original row numbers. Review each duplicate group, including its first occurrence.