Skip to content

Writing a Validation Rule for a Date Field That Rejects Impossible Dates

9 min read · updated August 11, 2026

A date validator that is one regular expression will accept 30 February and reject a legitimate 1 March written in a format you did not think of. The working shape is three layers: a pattern for the shape, a real calendar for validity, and a domain range for plausibility. Only the first is a regex.

Three layers, in order

Keep the layers separate, because they fail for different reasons and a reviewer needs to know which one rejected the value.

  • Shape. Does this string look like a date at all, and in which of the formats this document type uses? This is the only regex, and it should be a small set of anchored alternatives rather than one permissive pattern.
  • Calendar validity. Do these components name a day that exists? Never implement this. Use the language’s date library, which already knows the Gregorian rules.
  • Domain range. Is this date possible for this field? A date of birth is in the past and within a human lifespan; an invoice date is not years in the future; a service date falls inside a policy period.

Report which layer failed. “Did not match any expected format” sends a reviewer to look at the page; “30 February does not exist” is almost always a digit misread; “date of birth in the future” is usually a century error. Three different human actions, and a single boolean gives the reviewer none of them.

The parse that silently repairs

This is the specific trap. Many date constructors take year, month and day as numbers and normalise out-of-range components by rolling over rather than by failing. In JavaScript, constructing a date from components for the thirtieth of February yields the second of March, with no error and no warning — the value is simply wrong and plausible. A validator built as “regex for the shape, then construct a date object, then check it constructed” therefore accepts every impossible date it was written to catch.

// JavaScript: component construction rolls over, silently.
new Date(2026, 1, 30).toISOString().slice(0, 10);   // "2026-03-02"

// Parsing a full ISO date string does reject it.
Number.isNaN(new Date("2026-02-30").getTime());     // true

// Python raises instead, which is the behaviour you want.
// datetime.date(2026, 2, 30)  -> ValueError: day is out of range for month

So the calendar layer must either use a parser that rejects, or round-trip: construct the date, format it back to components, and require that they equal what went in. The round-trip test is the portable version and works in any language, including the ones whose libraries roll over.

Leap years and one famous off-by-one

The Gregorian rule is that a year is a leap year if it is divisible by four, except centuries, which must be divisible by four hundred. So 1900 was not a leap year and 2000 was. Do not write this out; the point of stating it is that a hand-rolled validator gets the century case wrong, and the bug is invisible for decades at a time.

There is a related trap that matters if any of your documents come from spreadsheets. Common spreadsheet date serial numbers are counted from the very end of 1899 and include a day that never existed, because the serial system treats 1900 as a leap year for compatibility with an older product. Every serial number after that phantom day is therefore offset by one relative to a naive conversion. If you convert serials yourself rather than letting a library do it, dates before March 1900 and dates after it need different arithmetic — which is a good reason to let the library do it.

Resolving day-month ambiguity

03/04/2026 is the third of April or the fourth of March, and no amount of validation resolves it in isolation because both readings are valid dates. Three sources of evidence, in decreasing order of strength.

  1. Another date on the same document. If any date in the same corpus of pages has a first component above twelve, the order is settled for the whole document — a document does not mix conventions. This is the strongest and most underused signal: scan all dates first, decide the order, then parse.
  2. The document’s own context. A stated country, a language, an issuer known to you, a form revision that specifies the format in its instructions.
  3. A configured default per source. Weakest, and the one to make explicit rather than implicit: if you are defaulting, record on the field that the order was assumed, so a wrong assumption is findable later.

Where none of the three applies, do not pick. Emit both candidate interpretations and route the field for review; an ambiguous date resolved by a coin toss is the sort of error that surfaces a year later in a contract dispute. Two-digit years need the same treatment: the pivot year that maps 28 to 2028 rather than 1928 is a policy, and it should be written down as one and chosen per field, since a date of birth and an expiry date want opposite pivots.

Valid dates that are still wrong

The layer people skip is the range check, and it is the one that catches recognition errors, because a misread digit usually produces a date that exists. Character confusions on a degraded scan are systematic rather than random — a digit is misread as one that looks like it, so a zero becomes an eight, a five becomes a six, a one becomes a seven — and the result is a perfectly well-formed date in the wrong decade.

Range rules are cheap and they catch a large fraction of these.

  • Date of birth: strictly in the past, and within a plausible lifespan. Choose the upper bound consciously; a hard hundred-and-twenty-year limit is defensible for most systems and should be a named constant rather than a literal buried in a condition.
  • Issue and execution dates: not in the future, with a small tolerance for time-zone skew rather than none, or a document issued today in a later time zone fails.
  • Expiry dates: after the issue date on the same document. This cross-field check catches a swap that no per-field rule can see, since both dates are individually fine.
  • Any date on a scanned document: compare the century against the document’s other dates. A single date in 1926 among dates in 2026 is a misread leading digit, not a historical record.

The rule, end to end

from datetime import date, datetime, timedelta
import re

SHAPES = [
    (re.compile(r"^(\d{4})-(\d{2})-(\d{2})$"),      "ymd"),
    (re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})$"),  "ambiguous"),
    (re.compile(r"^(\d{1,2}) ([A-Za-z]{3,9}) (\d{4})$"), "dmy_named"),
]

def validate_date(raw, *, field, day_first=None, today=None):
    today = today or date.today()
    s = raw.strip()

    for pattern, kind in SHAPES:
        m = pattern.match(s)
        if m:
            break
    else:
        return {"ok": False, "layer": "shape", "reason": "no known date format"}

    if kind == "ymd":
        y, mo, d = (int(g) for g in m.groups())
    elif kind == "dmy_named":
        try:
            parsed = datetime.strptime(s.title(), "%d %B %Y")
        except ValueError:
            parsed = datetime.strptime(s.title(), "%d %b %Y")
        y, mo, d = parsed.year, parsed.month, parsed.day
    else:
        if day_first is None:
            return {"ok": False, "layer": "shape",
                    "reason": "ambiguous order; resolve at document level",
                    "candidates": [f"{m.group(3)}-{m.group(2)}-{m.group(1)}",
                                   f"{m.group(3)}-{m.group(1)}-{m.group(2)}"]}
        a, b = int(m.group(1)), int(m.group(2))
        d, mo = (a, b) if day_first else (b, a)
        y = int(m.group(3))

    try:
        value = date(y, mo, d)                 # raises on 30 February
    except ValueError as e:
        return {"ok": False, "layer": "calendar", "reason": str(e)}

    if field == "date_of_birth":
        if value >= today:
            return {"ok": False, "layer": "range", "reason": "birth date not in past"}
        if value < today.replace(year=today.year - 120):
            return {"ok": False, "layer": "range", "reason": "implausible age"}
    if field in ("invoice_date", "issue_date"):
        if value > today + timedelta(days=1):
            return {"ok": False, "layer": "range", "reason": "issued in the future"}

    return {"ok": True, "value": value.isoformat()}
  1. Collect every date-shaped string on the document before validating any of them, and decide the day-month order once for the document.
  2. Run the three layers per field, passing the resolved order in. Keep the raw string alongside the parsed value — always, since a reviewer needs to see what was printed.
  3. Apply the cross-field ordering rules that only exist when a document has several dates, as described on multi-entity schema design.
  4. Route a failure by layer rather than as one error class, and attach the failing layer to the field so the review queue can group by it.

The same three-layer structure applies to the other two field types worth validating hard: currency amounts, where the ambiguity is the separator rather than the order, and identifiers with a checksum, where the calendar layer is replaced by arithmetic that is far stronger than any pattern.