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.

PythonLast tested Get the complete example ↓
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.

Install
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapi
API key
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'
Run
python3 clean-date-csv.python.py

Turn 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:

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:

Terminal
python3 clean-date-csv.python.py dates.csv --date-order dmy --output dates-clean.csv

Use 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

Python
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.

Download Python
View and copy the complete source
clean-date-csv.python.py
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.

Build something else

All tutorials →