Excel and document tasks

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.

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 exampleMeaningPython format
2026-09-01year-month-day%Y-%m-%d
09/02/2026month/day/year%m/%d/%Y
2026/09/03year/month/day%Y/%m/%d
04-09-2026day-month-year%d-%m-%Y
2026.09.05year.month.day%Y.%m.%d
20260906yearmonthday%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_idOriginal valueExpected standardized valueStatus
R0012026-09-012026-09-01PARSED
R00209/02/20262026-09-02PARSED
R0032026/09/032026-09-03PARSED
R00404-09-20262026-09-04PARSED
R0052026.09.052026-09-05PARSED
R006202609062026-09-06PARSED
R0072026-02-30UNPARSED
R008Sep 7 2026UNPARSED

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_idevent_datestandardized_datedate_status
R0012026-09-012026-09-01PARSED
R00209/02/20262026-09-02PARSED
R0032026/09/032026-09-03PARSED
R00404-09-20262026-09-04PARSED
R0052026.09.052026-09-05PARSED
R006202609062026-09-06PARSED
R0072026-02-30UNPARSED
R008Sep 7 2026UNPARSED

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_numberrecord_idevent_date
8R0072026-02-30
9R008Sep 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.

text
Processed 8 records.
Parsed: 6; unparsed: 2.
Cleaned CSV: outputs/date_cleanup_result/cleaned_dates.csv
Review CSV: outputs/date_cleanup_result/unparsed_dates.csv
  1. Confirm that cleaned_dates.csv contains 8 data rows rather than only the 6 successful rows.
  2. Confirm that standardized dates run from 2026-09-01 through 2026-09-06 for R001 through R006.
  3. Confirm that R007 and R008 have empty standardized_date values and UNPARSED status.
  4. Confirm that unparsed_dates.csv contains exactly R007 and R008.
  5. Run the script again without changing OUTPUT_DIR. It should stop with FileExistsError instead of overwriting the previous result.

07Recognize common errors

SymptomWhat to check
FileNotFoundErrorCheck SOURCE and the terminal's current working directory.
Missing required columnConfirm that the header contains event_date exactly, or change DATE_COLUMN deliberately.
Too many unparsed valuesInspect the source conventions and add only formats that are documented and unambiguous for that dataset.
Unexpected month and day reversalCheck whether slash or hyphen dates are month-first or day-first before adding their format.
FileExistsErrorThe result folder already exists. Review it and select a fresh output destination instead of overwriting it.
Valid-looking date still failsThe 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.

Site-wide writing and verification principles

References

The explanations and examples were written for this site. See the official sources below for the related behavior and concepts.