Find duplicate Excel rows and separate them for review
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.
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.
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.
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.
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.
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.
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.
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.
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()
python date_format_cleanup.pycleaned_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 |
The expected console output below is derived by hand from the synthetic input and script. It is not a captured execution log.
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| 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.
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.
ChatGPT Python tool · Python 3.13.5 · standard library csv/datetime/pathlib
The explanations and examples were written for this site. See the official sources below for the related behavior and concepts.