How to Clean and Standardise Date Columns in Spatial Data

On this page

Problem statement

The records have a date column that looks fine until you try to use it. This page works on 3,858 real hedgehog sightings from Scotland, published to GBIF by three recorders: The Wildlife Information Centre's hedgehog survey, the Fife Nature Records Centre, and iNaturalist. Each sighting is a point, and each carries the date exactly as the recorder wrote it, in GBIF's verbatimEventDate:

print(gdf["survey_date"].dtype, "|", len(gdf), "rows")
print(gdf.groupby("dataset")["survey_date"].first().to_string())
print(gdf["survey_date"].sample(8, random_state=4).tolist())
str | 3858 rows
dataset
Fife                    19/05/2025
TWIC                    06/06/2024
iNaturalist    2026-03-27 23:36:19
['02/10/2018', '28/06/2018', '2025-08-12 20:59:14+01:00', '01/08/2012', '1991 - 1997', '2022-05-06 22:47:30+01:00', '06/07/2022', '13/05/2021']

Day-first dates, months with no day, bare years, date ranges, iNaturalist timestamps with offsets, zone abbreviations and whatever a phone's JavaScript produced β€” all in one text column. Sorting it sorts text, filtering by year does not work, and a spatial layer written from it stores the whole mess as strings.

Dates in GIS data are unusually messy because the data has usually been through a form, a spreadsheet or an app, and at least one locale. In this column the causes are:

  • day-first dates that pandas reads month-first unless told otherwise
  • partial dates β€” a month, a year, a season β€” that a parser silently turns into the 1st of the month or of January
  • ranges written as two dates, for records that were only ever known to fall in a window
  • a placeholder year (1200) meaning "earliest date unknown"
  • timestamps with numeric offsets, zone abbreviations (BST, PDT) and browser output such as GMT+0100 (GMT+1)

Quick answer

Look at the value shapes, then parse each shape with an explicit format and keep what the value can actually tell you β€” a day, a month, a year or a window:

raw = gdf["survey_date"].astype("string").str.strip()
truth = pd.to_datetime(gdf["gbif_date"].str[:10], format="%Y-%m-%d", errors="coerce")

guessed = pd.to_datetime(raw, errors="coerce")                       # let pandas guess
right = (guessed.dt.normalize() == truth).sum()
print(f"guessing:  {guessed.notna().sum()} parsed, {right} on the right day")

day_first = pd.to_datetime(raw, format="%d/%m/%Y", errors="coerce")  # one shape, one rule
print(f"%d/%m/%Y:  {day_first.notna().sum()} parsed, {(day_first == truth).sum()} on the right day")
guessing:  1180 parsed, 79 on the right day
%d/%m/%Y:  3061 parsed, 3061 on the right day

Letting pandas guess is the worst of both worlds: it took its format from the first row, 06/06/2024, decided that was month-first, and then parsed only the rows that also happen to read as a valid month-first date β€” putting most of those on the wrong day β€” and turned everything else into NaT without a warning. One explicit format gets every day-first row right, and leaves the other shapes for formats of their own. clean_dates(), built step by step below, reads every shape in this column except four seasons.

  1. inventory the raw values β€” mask the digits and count the shapes
  2. settle day-first against month-first by counting components over 12
  3. parse each shape with an explicit format= and errors="coerce"
  4. convert timestamps to one zone before taking the calendar date
  5. keep partial dates and ranges as a start, an end and a precision
  6. replace placeholder dates with NaT, then check against an independent parse

Where this date column goes wrong

Six problems in 3,858 real hedgehog record dates and the fix for each. six problems in one real columnWhat the column holdsFix06/06/2024 read month-firstExplicit format="%d/%m/%Y"1,180 dates like 03/04/2019Count parts over 12: 1,881 to 02019-07 becomes 1 JulyKeep start, end and precision01/06/2010 - 30/06/2010Split and parse each half1200 - 1995Placeholder start becomes NaTGMT+01:00 read as -01:00Rewrite zones to offsets firstOnly the undecidable rows need judgement, and the column settles them spatialworkflow.io
What this real column contains, and what each problem needs.

Inventory the formats, then parse each one explicitly

Cleaning the hedgehog date column from inventory to a check against GBIF. look before you parse1inventory the value shapes2,452 values, 84 shapes2settle day-first1,881 prove it, 0 against3one explicit format per shapeerrors="coerce"4zones to offsets, then LondonGMT+01:00, BST, PDT5windows and placeholders51 ranges, 4 open starts6check against GBIF3,456 of 3,457 agreeFour of 3,858 values were left unparsed β€” all of them seasons spatialworkflow.io
Six stages, with what each one found in the 3,858 records.

Inventory the raw values first

Never parse a column you have not looked at.

shape = raw.str.replace(r"\d", "9", regex=True)
print("nulls:", raw.isna().sum(), "| distinct values:", raw.nunique(), "| distinct shapes:", shape.nunique())
print(shape.value_counts().head(12).to_string())
nulls: 0 | distinct values: 2452 | distinct shapes: 84
survey_date
99/99/9999                   3061
9999-99                       231
9999-99-99 99:99:99           181
9999-99-99 99:99:99+99:99      72
9999                           68
9999/99/99 9:99 PM             22
9999 - 9999                    19
99/99/9999 - 99/99/9999        17
July 9999                      16
99/99/9999-99/99/9999          14
June 9999                      14
9999/99/99 99:99 PM            13

Masking digits turns 2,452 distinct values into 84 shapes, and each shape maps to one parsing rule. The long tail matters as much as the head: the browser-style strings, the zone abbreviations and the seasons each appear only a handful of times, and each needs its own decision.

Settle day-first against month-first by counting

03/04/2019 is 3 April or 4 March, and no amount of code can tell you which for that one row. What code can do is measure whether the column as a whole is decidable:

slashed = raw.where(raw.str.fullmatch(r"\d{2}/\d{2}/\d{4}"))
first = pd.to_numeric(slashed.str[:2], errors="coerce")
second = pd.to_numeric(slashed.str[3:5], errors="coerce")
print("dd/mm/yyyy-shaped rows          :", slashed.notna().sum())
print("first part > 12 (day-first)     :", (first > 12).sum())
print("second part > 12 (month-first)  :", (second > 12).sum())
print("both 12 or under (undecidable)  :", ((first <= 12) & (second <= 12)).sum())
dd/mm/yyyy-shaped rows          : 3061
first part > 12 (day-first)     : 1881
second part > 12 (month-first)  : 0
both 12 or under (undecidable)  : 1180

Every row that can prove anything proves day-first, and none proves month-first, so the 1,180 undecidable rows are read day-first with a clear conscience. If both counts had been non-zero, the column would hold two conventions and no single parse would be right β€” you would need the source system's locale, or a second field, to split it.

What the defaults do with a column like this

How many of 3,858 hedgehog record dates each parsing approach put on the right day. 3,858 record dates, rows on the right daypd.to_datetime(raw)79 (1,180 parsed)format="mixed"ValueErrormixed, dayfirst, utc3,269 (ISO dates swapped)dayfirst=True3,061 (other shapes NaT)explicit format per shape3,456 of 3,457017503500Guessing parsed a third of the column and put 93% of that on the wrong day spatialworkflow.io
Rows on the right day, measured against GBIF's own parse of the same records.
for label, kwargs in [("no arguments", {}),
                      ("dayfirst=True", {"dayfirst": True}),
                      ('format="mixed"', {"format": "mixed"}),
                      ('format="mixed", dayfirst, utc', {"format": "mixed", "dayfirst": True, "utc": True})]:
    try:
        attempt = pd.to_datetime(raw, errors="coerce", **kwargs)
    except ValueError as exc:
        print(f"{label:30} ValueError: {str(exc)[:60]}")
        continue
    local = attempt.dt.tz_convert("Europe/London").dt.tz_localize(None) if kwargs.get("utc") else attempt
    ok = (local.dt.normalize() == truth).sum()
    print(f"{label:30} {attempt.notna().sum():5} parsed, {ok:5} on the right day")
iso = raw.str.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}") & raw.str[8:10].le("12")
swapped = pd.to_datetime(raw[iso], format="mixed", dayfirst=True)
print("mixed + dayfirst on ISO rows:", raw[iso].iloc[0], "->", swapped.iloc[0].date())
no arguments                    1180 parsed,    79 on the right day
dayfirst=True                   3061 parsed,  3061 on the right day
format="mixed"                 ValueError: Mixed timezones detected. Pass utc=True in to_datetime or tz
format="mixed", dayfirst, utc   3731 parsed,  3269 on the right day
mixed + dayfirst on ISO rows: 2026-04-01 23:10:38 -> 2026-01-04

Each default fails differently. With no arguments pandas guesses one format from the first row. dayfirst=True reads the dominant shape correctly and leaves the rest as NaT. format="mixed" parses row by row, but refuses outright β€” even with errors="coerce" β€” once it meets two different offsets. Adding utc=True gets it through, and dayfirst=True is then applied to the ISO timestamps too, so 2026-04-01 becomes 4 January. Only explicit formats remove the guessing.

Parse each shape with an explicit format

parse_part() tries one explicit format at a time and only fills rows that are still empty, recording what each value could tell it:

def parse_part(text):
    """Parse one date or timestamp per row into a start date, an end date and a precision."""
    start = pd.Series(pd.NaT, index=text.index, dtype="datetime64[us]")
    end = start.copy()
    precision = pd.Series(pd.NA, index=text.index, dtype="string")

    def take(values, prec, span=None):
        todo = start.isna() & values.notna()
        start[todo] = values[todo]
        end[todo] = values[todo] if span is None else values[todo] + span
        precision[todo] = prec

    take(pd.to_datetime(text, format="%d/%m/%Y", errors="coerce"), "day")
    take(pd.to_datetime(text, format="%Y-%m-%d", errors="coerce"), "day")
    for fmt in ("%Y-%m", "%B %Y"):
        take(pd.to_datetime(text, format=fmt, errors="coerce"), "month", pd.offsets.MonthEnd(0))
    take(pd.to_datetime(text, format="%Y", errors="coerce"), "year", pd.offsets.YearEnd(0))

    # timestamps with no offset: the observer's local time
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y/%m/%d %I:%M %p",
                "%Y/%m/%d %H:%M %p", "%Y/%m/%d %H:%M", "%Y/%m/%d"):
        ts = pd.to_datetime(text, format=fmt, errors="coerce")
        take(ts.dt.normalize(), "day")

    # timestamps with an offset or a zone name: to UTC, then to the local calendar date
    aware = text.replace(ZONES, regex=True)
    for fmt in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %I:%M:%S %p%z",
                "%Y/%m/%d %I:%M %p%z", "%b %d %Y %H:%M:%S%z"):
        ts = pd.to_datetime(aware, format=fmt, errors="coerce", utc=True)
        take(ts.dt.tz_convert("Europe/London").dt.tz_localize(None).dt.normalize(), "day")
    return start, end, precision

The order is part of the rule: the strict day-first format runs first, so the dominant shape is decided by the rule you chose, and a looser format only ever sees the rows the stricter ones left. A value that matches nothing stays NaT and is counted, not guessed.

Put timestamps in one zone before taking the date

Recorders' apps wrote offsets in four different ways. Two of them defeat the parser:

for text in ["2023-07-30 3:36:23 PM GMT+01:00", "2021/04/17 9:37 PM BST"]:
    try:
        print(f"{text:34} -> {pd.to_datetime(text)}")
    except ValueError as exc:
        print(f"{text:34} -> {type(exc).__name__}: {str(exc)[:55]}")
2023-07-30 3:36:23 PM GMT+01:00    -> 2023-07-30 15:36:23-01:00
2021/04/17 9:37 PM BST             -> ValueError: Parsed string "2021/04/17 9:37 PM BST" included an un-r

The first one is worse than an error. The recorder meant one hour ahead of GMT; dateutil, which pandas falls back to, follows the POSIX convention in which GMT+1 means one hour behind, and returns -01:00 β€” two hours out. The second simply fails, because a zone abbreviation is not an offset (BST is also Bangladesh Standard Time). So the text is rewritten first, into offsets that mean one thing:

ZONES = {r"\s*\(.*\)$": "",                              # "(GMT+1)" browser label
         r"^[A-Z][a-z]{2} (?=[A-Z][a-z]{2} \d)": "",     # "Sun " weekday
         r"\s*GMT\s*(?=[+-]\d)": "",                     # "GMT+01:00" -> "+01:00"
         r" GMT$": "+00:00", r" BST$": "+01:00", r" EDT$": "-04:00",
         r" CDT$": "-05:00", r" MDT$": "-06:00", r" PDT$": "-07:00"}
examples = pd.Series(["2023-07-30 3:36:23 PM GMT+01:00", "2021/04/17 9:37 PM BST",
                      "Sat Aug 07 2021 15:11:04 GMT -0700 (PDT)"], dtype="string")
print(examples.replace(ZONES, regex=True).tolist())
start, end, precision = parse_part(examples)
print(start.dt.date.tolist(), precision.tolist())
['2023-07-30 3:36:23 PM+01:00', '2021/04/17 9:37 PM+01:00', 'Aug 07 2021 15:11:04-0700']
[datetime.date(2023, 7, 30), datetime.date(2021, 4, 17), datetime.date(2021, 8, 7)] ['day', 'day', 'day']

Converting to Europe/London before taking the date matters for the late-evening sightings β€” hedgehogs are nocturnal, and a phone set to Pacific time logged 15:11, which was 23:11 in Scotland. Timestamps with no offset at all are iNaturalist's local times and are taken as written.

Keep partial dates and ranges as a window

print(pd.to_datetime("2019-07"), "|", pd.to_datetime("July 2019"))
2019-07-01 00:00:00 | 2019-07-01 00:00:00

A parser that is handed a month returns the first of that month, and nothing downstream can tell it apart from a sighting on 1 July. So a partial value keeps a start, an end and a precision, and a range is split into two parts that are each parsed the same way:

def clean_dates(raw):
    start, end, precision = parse_part(raw)
    halves = raw.where(start.isna()).str.extract(r"^(.+?)\s*-\s*(\S.*)$")
    first, _, _ = parse_part(halves[0])
    _, last, _ = parse_part(halves[1])
    is_range = first.notna() & last.notna()
    start[is_range], end[is_range], precision[is_range] = first[is_range], last[is_range], "range"

    placeholder = start.dt.year < 1900                 # "1200 - 1995": earliest date unknown
    start[placeholder] = pd.NaT
    return start, end, precision

start, end, precision = clean_dates(raw)
print(precision.value_counts(dropna=False).to_string())
print("unparsed:", raw[precision.isna()].tolist())
day      3457
month     278
year       68
range      51
<NA>        4
unparsed: ['Summer 2013', 'Spring 1998', 'Summer 1995', 'Autumn 1994']
The precision each cleaned hedgehog record date carries. what each value could tell usprecisionrowsexamplestored asday3,45706/06/2024start = endmonth2782024-081 Aug to 31 Augyear6820111 Jan to 31 Decrange511960 - 1993start to endnone4Summer 1995quarantinedTurning every row into one timestamp would invent the precision of 397 records spatialworkflow.io
Most records are dated to the day; the rest are real information at a coarser grain.

Replace placeholder dates, then check the range

print("placeholder starts:", raw[raw.str.startswith(("1200", "01/01/1200"))].tolist())
lo, hi = pd.Timestamp("1900-01-01"), pd.Timestamp.today().normalize()
print("earliest start:", start.min().date(), "| latest end:", end.max().date())
print("outside 1900..today:", int(((start < lo) | (end > hi)).sum()))
placeholder starts: ['01/01/1200 - 06/04/2004', '01/01/1200 - 06/04/2004', '1200 - 1995', '1200 - 1996']
earliest start: 1960-01-01 | latest end: 2026-09-28
outside 1900..today: 0

Four records start in the year 1200. That is not a date, it is the recorder's way of writing "some time before": the ends of those windows are real and are kept, while the start becomes NaT, which is what it meant. Nothing parsed lands after today or before 1900, but this is the check that would catch a 9999-12-31, or the 1899-12-30 that an empty Excel cell becomes, if a different source used one.

Look for Excel serial numbers

numeric = pd.to_numeric(raw, errors="coerce")
print("numbers in the column:", numeric.notna().sum(), "| in the Excel serial range:", numeric.between(20000, 60000).sum())

from openpyxl.utils.datetime import from_excel
for serial in (45447, 59, 61):
    print(serial, pd.to_datetime(serial, unit="D", origin="1899-12-30").date(), from_excel(serial).date())
numbers in the column: 68 | in the Excel serial range: 0
45447 2024-06-04 2024-06-04
59 1900-02-27 1900-02-28
61 1900-03-01 1900-03-01

This column has none β€” its only numbers are four-digit years β€” but a column that went through a spreadsheet often does. Counting days from 1899-12-30 is the usual conversion, and it agrees with openpyxl from 1 March 1900 onwards; below serial 61 the two disagree by a day, because Excel counts a 29 February 1900 that never existed.

Check against an independent parse

GBIF parses every record's date itself when it indexes it, and publishes the result as eventDate. That is a second opinion worth having:

day = precision.eq("day") & truth.notna()
print(f"day-precision rows GBIF also dated: {day.sum()}, agree {(start[day] == truth[day]).sum()}")
odd = day & (start != truth)
print(pd.DataFrame({"raw": raw[odd], "ours": start[odd].dt.date, "gbif": gdf["gbif_date"][odd]}).to_string(index=False))
print("GBIF gave no date to", gdf["gbif_date"].isna().sum(), "records; our precision for them:",
      precision[gdf["gbif_date"].isna()].value_counts().to_dict())
day-precision rows GBIF also dated: 3457, agree 3456
                      raw       ours                gbif
2022-04-12 23:50:34+01:00 2022-04-12 2022-04-13T00:50:34
GBIF gave no date to 25 records; our precision for them: {'range': 25}

The one disagreement is GBIF's: a sighting at 23:50 with an offset of +01:00 is 23:50 in Scotland, and GBIF's value is an hour later, on the next day. The records GBIF could not date at all are the multi-year windows, which a single timestamp cannot represent and a start–end pair can.

Store one canonical form

gdf["date_start"], gdf["date_end"], gdf["date_precision"] = start, end, precision
Path("data/clean").mkdir(parents=True, exist_ok=True)
gdf.to_file("data/clean/hedgehogs.gpkg", driver="GPKG")

# one record, plus the Pacific-time sighting's timestamp in Scotland's zone
sample = gdf.head(1).assign(seen_at=pd.Timestamp("2021-08-07 15:11:04-07:00").tz_convert("Europe/London"))
for path in ("data/clean/sample.shp", "data/clean/sample.gpkg", "data/clean/sample.geojson", "data/clean/sample.parquet"):
    if path.endswith(".parquet"):
        sample.to_parquet(path)
        back = gpd.read_parquet(path)
    else:
        sample.to_file(path)
        back = gpd.read_file(path)
    print(f"{path.rsplit('.', 1)[1]:8} date_start {str(back['date_start'].dtype):15} "
          f"seen_at {str(back['seen_at'].dtype):31} {back['seen_at'].iloc[0]}")
shp      date_start str             seen_at str                             2021/08/07 23:11:04+01
gpkg     date_start datetime64[ms]  seen_at datetime64[ms, UTC+01:00]       2021-08-07 23:11:04+01:00
geojson  date_start datetime64[ms]  seen_at datetime64[ms, UTC+01:00]       2021-08-07 23:11:04+01:00
parquet  date_start datetime64[us]  seen_at datetime64[us, Europe/London]   2021-08-07 23:11:04+01:00
What each format gives back of a date and a zoned timestamp written from GeoPandas. what comes back after a round tripShapefileGeoPackageGeoJSONGeoParquetdateβœ•as textβœ“yesβœ“yesβœ“yestime of dayβœ•as textβœ“yesβœ“yesβœ“yesUTC offsetβœ•as text!fixed +01:00!fixed +01:00βœ“yeszone nameβœ•noβœ•noβœ•noβœ“Europe/LondonGeoPandas 1.1.4, pyogrio 0.13, GDAL 3.12.4: only Parquet keeps the zone spatialworkflow.io
Where the value ends up matters as much as how you parsed it.

Only GeoParquet brings the zone back: GeoPackage and GeoJSON keep the instant and its offset but not the name Europe/London, so arithmetic across a clock change on the read-back column is done in a fixed +01:00. The shapefile returns text β€” GDAL writes a datetime to a .dbf as a string such as 2021/08/07 23:11:04+01, and reading it back does not turn it into a date again. If a shapefile is required, write an ISO date string column yourself so its contents are at least unambiguous.

Add the derived fields your analysis needs once, rather than re-parsing downstream:

gdf["year"] = gdf["date_start"].dt.year.astype("Int64")
gdf["month"] = gdf["date_start"].where(gdf["date_precision"].eq("day")).dt.month.astype("Int64")
print(gdf[["survey_date", "date_start", "date_end", "date_precision", "year", "month"]].sample(4, random_state=2).to_string(index=False))
survey_date date_start   date_end date_precision  year  month
 24/04/2011 2011-04-24 2011-04-24            day  2011      4
 09/05/1993 1993-05-09 1993-05-09            day  1993      5
 26/06/1994 1994-06-26 1994-06-26            day  1994      6
 10/06/2011 2011-06-10 2011-06-10            day  2011      6

month is only filled where the precision is a day: a record known only to fall in 2019 has a year but no month, and giving it January would put a hibernating animal in the wrong season.

Cleaning hedgehog sighting dates from GBIF

The data used on this page

Hedgehog (Erinaceus europaeus, GBIF taxon 5219616) occurrences in Scotland from three datasets on GBIF: the TWIC Hedgehog Public Survey (CC BY), the Fife Nature Records Centre Combined Dataset (CC BY) and iNaturalist Research-grade Observations (CC BY-NC). They are requested from the GBIF occurrence API and cached; survey_date is the recorder's verbatimEventDate, gbif_date is GBIF's own parse of it.

import hashlib
import io
import json
import zipfile
from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests

# GBIF adds and revises records daily. So that the numbers below reproduce, every GBIF request on
# this page is answered from the reply it got on 9 October 2026 (one file per request, named by a
# SHA-1 of its URL); any other request, or all of them once these lines are deleted, goes live.
SAVED = zipfile.ZipFile(io.BytesIO(requests.get(
    "https://data.spatialworkflow.io/gbif-api-2026-10-09.zip", timeout=300).content))
live_get = requests.get


def saved_get(url, params=None, **kwargs):
    prepared = requests.Request("GET", url, params=params).prepare()
    try:
        body = SAVED.read(hashlib.sha1(prepared.url.encode()).hexdigest()[:16] + ".json")
    except KeyError:
        return live_get(url, params=params, **kwargs)
    reply = requests.models.Response()
    reply.status_code, reply._content, reply.url = 200, body, prepared.url
    reply.headers["Content-Type"] = "application/json"
    return reply


requests.get = saved_get

DATASETS = {"52922205-6477-4999-9d4a-d38999aa32eb": "TWIC",
            "c358edb6-9b46-45eb-a173-b854910500a9": "Fife",
            "50c9509d-22c7-4a22-a47d-8c48425ef4a7": "iNaturalist"}
cache = Path("data/hedgehogs.json")
if not cache.exists():
    cache.parent.mkdir(exist_ok=True)
    rows = []
    for key, name in DATASETS.items():
        offset = 0
        while True:
            page = requests.get("https://api.gbif.org/v1/occurrence/search", timeout=120, params={
                "taxonKey": 5219616, "gadmGid": "GBR.3_1", "hasCoordinate": "true",
                "datasetKey": key, "limit": 300, "offset": offset}).json()
            rows += [{"gbifID": r["key"], "dataset": name, "verbatimEventDate": r.get("verbatimEventDate"),
                      "eventDate": r.get("eventDate"), "lon": r["decimalLongitude"], "lat": r["decimalLatitude"]}
                     for r in page["results"]]
            offset += 300
            if page["endOfRecords"]:
                break
    cache.write_text(json.dumps(rows))

records = pd.DataFrame(json.loads(cache.read_text()))
gdf = gpd.GeoDataFrame(
    records[["gbifID", "dataset"]].assign(survey_date=records["verbatimEventDate"],
                                          gbif_date=records["eventDate"]),
    geometry=gpd.points_from_xy(records["lon"], records["lat"]), crs=4326)
print(len(gdf), "records:", gdf["dataset"].value_counts().to_dict())
3858 records: {'TWIC': 2589, 'Fife': 873, 'iNaturalist': 396}

Example 1: a reusable date cleaner with a report

parse_part(), ZONES and clean_dates() above are the whole cleaner. Wrapped with a report, it becomes something a pipeline can log:

def date_report(raw, start, end, precision):
    return {"rows": len(raw), "blank": int(raw.isna().sum()),
            **{f"precision_{k}": int(v) for k, v in precision.value_counts().items()},
            "unparsed": int((precision.isna() & raw.notna()).sum()),
            "open_start": int((precision.eq("range") & start.isna()).sum()),
            "earliest": str(start.min().date()), "latest": str(end.max().date())}

start, end, precision = clean_dates(gdf["survey_date"].astype("string").str.strip())
for key, value in date_report(raw, start, end, precision).items():
    print(f"{key:16} {value}")
rows             3858
blank            0
precision_day    3457
precision_month  278
precision_year   68
precision_range  51
unparsed         4
open_start       4
earliest         1960-01-01
latest           2026-09-28

Example 2: quarantine what will not parse

unparsed = gdf.loc[precision.isna() & raw.notna(), ["gbifID", "dataset", "survey_date", "geometry"]]
Path("data/out").mkdir(parents=True, exist_ok=True)
unparsed.to_file("data/out/unparsed_dates.gpkg", driver="GPKG")
print(f"{len(unparsed)} rows quarantined:")
print(unparsed.drop(columns="geometry").to_string(index=False))
4 rows quarantined:
    gbifID dataset survey_date
1887123317    Fife Summer 2013
6067543483    Fife Spring 1998
6068176419    Fife Summer 1995
6067572888    Fife Autumn 1994

Writing the failures to their own layer keeps the pipeline moving while making sure nobody discovers the gap months later. "Summer 1995" could become a June-to-August window if the project decides what a season means; that is a decision for a person, not a parser.

Example 3: filter by date without inventing precision

year = pd.Timestamp("2019-01-01"), pd.Timestamp("2019-12-31")
exact = gdf["date_precision"].isin(["day", "month"]) & gdf["date_start"].between(*year)
inside = gdf["date_start"].ge(year[0]) & gdf["date_end"].le(year[1])
overlaps = gdf["date_start"].fillna(pd.Timestamp.min).le(year[1]) & gdf["date_end"].ge(year[0])
print(f"2019, day or month precision: {exact.sum()} | whole window inside 2019: {inside.sum()} | window overlaps 2019: {overlaps.sum()}")

by_month = gdf.loc[gdf["date_precision"].eq("day"), "month"].value_counts().sort_index()
print(by_month.to_string())
2019, day or month precision: 113 | whole window inside 2019: 117 | window overlaps 2019: 119
month
1      25
2       7
3     100
4     419
5     590
6     489
7     557
8     512
9     398
10    238
11     97
12     25

The three counts answer three different questions, and the difference between them is exactly the records whose date is a window. The month profile is only possible because month-level and year-level records were kept out of it: it shows the winter lull while hedgehogs hibernate β€” 25, 7 and 25 sightings in January, February and December, against 590 in May.

Example 4: assert date quality in the pipeline

def assert_dates_clean(frame, max_unparsed_pct=1.0):
    for col in ("date_start", "date_end"):
        if not pd.api.types.is_datetime64_any_dtype(frame[col]):
            raise TypeError(f"{col} is {frame[col].dtype}, expected datetime64")
    unparsed = frame["date_precision"].isna().mean() * 100
    if unparsed > max_unparsed_pct:
        raise ValueError(f"{unparsed:.1f}% of dates unparsed (limit {max_unparsed_pct}%)")
    backwards = frame["date_start"] > frame["date_end"]
    if backwards.any():
        raise ValueError(f"{backwards.sum()} windows end before they start")
    if (frame["date_end"] > pd.Timestamp.today()).any():
        raise ValueError("dates in the future")
    print(f"dates ok: {frame['date_precision'].notna().sum()} parsed, "
          f"{frame['date_start'].min().date()} to {frame['date_end'].max().date()}")

assert_dates_clean(gdf)
dates ok: 3854 parsed, 1960-01-01 to 2026-09-28

A date is a window, not a string

A date is not a string; it is a point or a window on a calendar that can be written in dozens of ways. Every messy value in a raw column is a rendering choice made by some earlier system β€” a survey form, a phone app, a browser, a records-centre export β€” and cleaning means recovering the underlying value and then choosing one rendering of your own.

Pandas will happily infer formats, and that convenience is the source of most date bugs. In pandas 2 and 3 inference takes one format from the first value and applies it to the whole column, so the same code gives different results depending on which row happens to come first; format="mixed" switches to per-value inference, which fails in other ways. Passing an explicit format= removes the ambiguity for the values that match it, and errors="coerce" turns the rest into NaT β€” visible, countable failures rather than silent misreadings.

Deciding whether a slash-separated date column is day-first by counting components over 12. is dd/mm or mm/dd decidable?Which part of nn/nn/yyyy ever exceeds12?only the firstDay-firsthedgehogs: 1,881 to 0only the secondMonth-firstparse %m/%d/%YbothTwo conventionssplit by sourceThe 1,180 undecidable rows follow the rest of their column spatialworkflow.io
The whole column decides the rows that cannot decide themselves.

The genuinely undecidable case deserves attention rather than a default. Counting first and second components over 12 takes three lines and turns an invisible problem into either a clear answer, as here, or a specific question for whoever supplied the file.

Precision is the part most cleaning code throws away. A month, a year and a five-year window are all real information, and each is different from a day. Storing a start, an end and a precision keeps it; turning every value into one timestamp invents it. Darwin Core, the standard GBIF's records follow, does the same thing by allowing an interval such as 2019-06/2019-08 in eventDate.

Placeholders are the other quiet corruption. A year of 1200, or a 1900-01-01, parses perfectly, so it survives every technical check and then distorts every summary: minimum dates, ranges, "years since recorded", time-series plots. It means "unknown", and the honest representation of unknown is NaT.

Finally, storage. A shapefile's .dbf has a Date field with no time, and from GeoPandas a datetime does not even reach it β€” it is written as text. GeoPackage has Date and DateTime fields and GeoJSON has no date type at all, but GDAL reads ISO 8601 strings in both back as datetimes with their offset; only GeoParquet keeps the pandas dtype, zone name included. Choosing a format with the right ceiling β€” and writing an ISO string alongside when you must use an older one β€” keeps the value intact all the way to the consumer.

Excel serials, two-digit years and silent failures

  • Excel's leap-year bug: Serial numbers assume 1900 was a leap year. Using origin="1899-12-30" compensates; Mac-origin files may use 1904 instead.
  • Two-digit years: Pandas maps 69–99 to 1969–1999 and 00–68 to 2000–2068. If your data spans 1920s records, that default is wrong.
  • errors="ignore" is gone: Older pandas returned the input unchanged, leaving an object column that looked parsed. pandas 3 removed it; on 3.0.6, passing it fails with a bare AssertionError. Use coerce and count the NaTs.
  • Sorting text dates is meaningless: '14/05/1967' sorts after '06/06/2024' because 1 comes after 0. Always convert before sorting or filtering.
  • Year 1200 needs a wide timestamp: pandas 3 parses it into a microsecond-resolution column, but adding a nanosecond Timedelta to that column raises OutOfBoundsDatetime; that is why parse_part() adds a span only for months and years, as a calendar offset.
  • NaT propagates: Arithmetic with NaT yields NaT, which is correct but means aggregations need dropna() or skipna.
  • Timezones and shapefiles: Shapefiles cannot store an offset. Convert to one zone and write an ISO string if the time matters.
  • GMT+1 means two things: In a phone's display it is one hour ahead of GMT; in POSIX zone names such as Etc/GMT+1 it is one hour behind. Rewrite it to a numeric offset before parsing.
  • Locale-dependent month names: %b parses Mar in English but not MΓ€r. Pass the values through a mapping first when the source is non-English.

FAQ

Why does pandas parse the same column differently on two machines?

Because format inference has changed across pandas versions and can depend on the first values it sees. Pass an explicit format= (or dayfirst=) so the result is deterministic everywhere.

How do I handle a column with both day-first and month-first dates?

Detect it by counting values whose first component exceeds 12 and whose second component exceeds 12. If both exist, the column genuinely holds two conventions β€” split it by source or ask the supplier; no parser can resolve it.

What are the numbers in my date column?

Excel serial dates, counted in days from 1899-12-30 on Windows. Convert with pd.to_datetime(values, unit="D", origin="1899-12-30") and sanity-check the resulting years.

Should I use errors="coerce" or let parsing fail?

Use coerce in a batch context so one bad row cannot stop the run, then count and quarantine the resulting NaT values. Failing loudly is right only when any bad date should abort the job.

How do I store dates in a shapefile?

The dBase Date field has no time component, and from GeoPandas a datetime column does not reach it anyway: GDAL writes it as text such as 2024/06/06 00:00:00, which reads back as a string. Write an ISO 8601 string column yourself so the text is at least unambiguous, or use GeoPackage, which reads back real datetimes.

What should I do with dates like 1900-01-01?

Treat them as sentinels for "unknown" and convert to NaT. Left in place they parse cleanly and then corrupt every minimum, range and duration you compute.

How do I keep timezone information through the pipeline?

Parse with utc=True to get a uniform tz-aware column, derive local dates for display, and store either UTC ISO strings or GeoParquet, which preserves the timezone-aware dtype exactly.