Clean up a CSV of mixed dates
Parse mixed dates in a Python CSV into YYYY-MM-DD with an explicit date order and review rows for unresolved values.
On this page
Run the example
Download Pythonclean-date-csv.python.py
Download the file, then run these commands in its folder using a macOS or Linux terminal. You need Python 3.9 or later and a secret API key.
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapi
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'
python3 clean-date-csv.python.py --date-order dmyThe first run uses the included sample. The CSV instructions below show how to save your own results. Review rows can produce exit code 2 even when the output was written successfully.
Turn dates into YYYY-MM-DD
Clean mixed date formats in a Python CSV import by choosing the order used by the source. The script adds parse_date as YYYY-MM-DD and keeps the original date text beside it.
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 you don't know the source's order, omit --date-order: 03/04/2026 stays unresolved with date_order_required rather than becoming March 4 or April 3. 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 --date-order dmy, these selected output columns show both resolved and review rows:
id,parse_date,parse_status
003,2026-04-03,parsed
005,,invalid_date
006,,missing_dateThe same dmy rule turns 13/04/2026 into 2026-04-13. February 30 and the blank cell remain review rows, as shown above.
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? Set up this server example. For every field and parameter, see Date API reference.