Clean a CSV of mixed date formats with Python
Turn a date column into YYYY-MM-DD using one known date order. Keep unresolved rows for review.
On this page
Run the example
Download the complete source below. In a macOS or Linux terminal, install the SDK, set your secret API key, then run the file.
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapiexport PARSEAPI_KEY='YOUR_SECRET_API_KEY'python3 clean-date-csv.python.pyTurn dates into YYYY-MM-DD
Give the script a CSV with a date column. It appends parse_date in ISO format and keeps the original text for comparison.
Use Python 3.9 or later. Install the SDK and set your secret PARSEAPI_KEY with the setup above.
1. Choose the source's date order
Save this as dates.csv:
id,date
001,2026-03-04
002,"March 4, 2026"
003,03/04/2026
004,13/04/2026
005,2026-02-30
006,For a source using day/month/year, run:
python3 clean-date-csv.python.py dates.csv --date-order dmy --output dates-clean.csvUse mdy for month/day/year. The choice applies to every year-last numeric date, even 13/04/2026. The script never switches order to make an individual row pass.
If the source's order is unknown, omit --date-order: numeric rows wait for review. Year-first dates and English month names carry their own order. Mixed sources need a known interpretation per source before combining results.
2. Parse with that interpretation
result = parsed(parse.date(value, format=order), value, order)The Date API returns the ISO date. Repeated inputs reuse the same lookup and interpretation. Only the date and order go to ParseAPI.
Two-digit years, compact dates, timestamps, and relative terms remain unsupported_date_format in this example. A calendar-date import should not silently discard a time or offset.
3. Keep unresolved rows visible
With dmy, 03/04/2026 becomes 2026-04-03 and 13/04/2026 becomes 2026-04-13. February 30 stays invalid_date; the blank cell stays missing_date. Missing order produces date_order_required.
The output appends parse_date, parse_date_order, and three status/error columns. Unresolved dates keep an empty parse_date. Request failures remain errors, separate from an API result of valid: false.
The complete script uses the CSV parser to preserve every cell and row, including quoted commas and leading zeroes. Use distinct nonempty headers, a date column, and 1-200 rows within 64 KiB. Malformed rows and existing output columns are rejected before requests. Calls have a ten-second timeout, no automatic retries, and stop after account/rate errors.
The output filename must be new. Omit the input filename to use the included sample. Without --output, CSV goes to standard output; never redirect onto the input. Exit 0 means every row parsed, 2 means output includes review rows, and 1 means a startup or file failure.
Complete example
The complete Python source. The script reads your API key from PARSEAPI_KEY.
View and copy the complete source
import argparse
import csv
import io
import re
import sys
from pathlib import Path
from parseapi import ParseAPI, ParseAPIError
from datetime import date as CalendarDate
DESCRIPTION = "Clean a date CSV using one declared numeric date order."
REQUIRED = ["date"]
COLUMNS = ["parse_date", "parse_date_order", "parse_status", "parse_error", "parse_http_status"]
SAMPLE = """id,date,note
001,2026-03-04,Year first
002,"March 4, 2026",Month in words
003,03/04/2026,Choose the source's date order
004,13/04/2026,Same source order applies
005,2026-02-30,Impossible calendar date
006,,Missing date
"""
# example:read
def read_rows(stream):
reader = csv.reader(stream, strict=True)
fields = next(reader, None)
if (not fields or not set(REQUIRED).issubset(fields)
or any(not field.strip() for field in fields)
or len(fields) != len(set(fields))):
raise ValueError("Use distinct, nonempty headers including " + ", ".join(REQUIRED) + ".")
if set(fields) & set(COLUMNS):
raise ValueError("The input already contains output columns. Use the original CSV.")
rows = []
for cells in reader:
if len(cells) != len(fields):
raise ValueError("Every row must have the same number of cells as the header.")
rows.append(dict(zip(fields, cells)))
if len(rows) > 200:
raise ValueError("Use 1 to 200 data rows for this example.")
if not rows:
raise ValueError("The CSV needs at least one data row.")
return fields, rows
# /example:read
def outcome(status, error="", http_status="", **values):
return {**dict.fromkeys(COLUMNS, ""), "parse_status": status,
"parse_error": error, "parse_http_status": http_status, **values}
def write_rows(stream, fields, rows):
writer = csv.DictWriter(stream, fieldnames=fields + COLUMNS)
writer.writeheader()
writer.writerows(rows)
# example:interpret
def interpretation(value, date_order):
# A single choice governs all year-last numeric rows, including 13/04/2026.
if re.fullmatch(r"[0-9]{1,2}([/.-])[0-9]{1,2}\1[0-9]{4}", value):
return (None, "date_order_required") if date_order == "review" else (date_order, None)
year_first = re.fullmatch(r"[0-9]{4}([/.-])[0-9]{1,2}\1[0-9]{1,2}", value)
month_words = re.fullmatch(
r"(?:[A-Za-z]+\.? [0-9]{1,2}(?:st|nd|rd|th)?,? [0-9]{4}|"
r"[0-9]{1,2}(?:st|nd|rd|th)?[ -][A-Za-z]+\.?,?[ -][0-9]{4})", value)
if year_first or month_words:
return None, None
# Compact dates, two-digit years, and timestamps need an explicit cleanup rule.
return None, "unsupported_date_format"
# /example:interpret
# example:result
def parsed(data, value, order):
if not isinstance(data, dict) or type(data.get("valid")) is not bool:
return outcome("error", "unexpected_response")
if not data["valid"]:
if data.get("date") != value:
return outcome("error", "unexpected_response")
return outcome("invalid_date", http_status=200, parse_date_order=order or "explicit")
iso = data.get("date")
try:
if not isinstance(iso, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", iso):
raise ValueError()
CalendarDate.fromisoformat(iso)
except ValueError:
return outcome("error", "unexpected_response")
return outcome("parsed", http_status=200, parse_date=iso, parse_date_order=order or "explicit")
# /example:result
def enrich(rows, parse, date_order="review"):
if date_order not in ("review", "mdy", "dmy"):
raise ValueError("Choose review, mdy, or dmy for the date order.")
results, cache, stopped = [], {}, False
for row in rows:
value = " ".join(row["date"].split())
order, issue = interpretation(value, date_order)
identity = (value, order)
if not value:
result = outcome("missing_date")
elif len(value) > 64:
result = outcome("unsupported_date_format")
elif issue:
result = outcome(issue)
elif identity in cache:
result = cache[identity]
elif stopped:
result = outcome("not_attempted")
else:
try:
# example:request
result = parsed(parse.date(value, format=order), value, order)
# /example:request
except ParseAPIError as error:
result = outcome("error", error.code, error.status)
stopped = error.status in (401, 403, 429)
except Exception:
result = outcome("error", "request_failed")
cache[identity] = result
results.append({**row, **result})
return results
def main():
parser = argparse.ArgumentParser(description=DESCRIPTION)
parser.add_argument("input", nargs="?", help="UTF-8 CSV filename, or omit to run the sample")
parser.add_argument("--output", help="New CSV filename, or omit for standard output")
parser.add_argument("--date-order", choices=("review", "mdy", "dmy"), default="review")
args = parser.parse_args()
try:
if args.output and (Path(args.output).exists() or Path(args.output).is_symlink()):
raise ValueError("The output path already exists. Choose a new filename.")
if args.input:
path = Path(args.input)
if path.stat().st_size > 65536:
raise ValueError("Use a CSV no larger than 64 KiB.")
with path.open(encoding="utf-8-sig", newline="") as stream:
fields, rows = read_rows(stream)
else:
fields, rows = read_rows(io.StringIO(SAMPLE))
# Reads PARSEAPI_KEY. Reuse one client without automatic retries.
with ParseAPI(timeout=10.0, retries=0) as parse:
results = enrich(rows, parse, args.date_order)
if args.output:
# Exclusive creation protects against a file appearing during the lookups.
with Path(args.output).open("x", encoding="utf-8", newline="") as stream:
write_rows(stream, fields, results)
else:
write_rows(sys.stdout, fields, results)
except (ValueError, OSError, csv.Error) as error:
print(f"Could not complete: {error}", file=sys.stderr)
return 1
review = sum(row["parse_status"] != "parsed" for row in results)
print(f"{len(results)} rows written, {review} need review.", file=sys.stderr)
return 2 if review else 0
if __name__ == "__main__":
sys.exit(main())
Need a key? Create a secret API key. For every field and parameter, see Date API reference.