The date regex you can copy
The safest date shape is the ISO 8601 calendar date, and the regex for its surface form is tiny:
^\d{4}-\d{2}-\d{2}$
It demands four digits, a hyphen, two digits, a hyphen, two digits. That is the entire structural promise. It says nothing about whether the 30th of February exists, because regular expressions do not do arithmetic.
What this pattern actually matches
\d{4}— the year, four digits, no sign, no limit.0000and9999both pass.-— a literal hyphen separator, the ISO choice. (Slashes are not ISO and invite ambiguity.)\d{2}— the month, two digits.00and13both pass because the pattern only counts digits.\d{2}— the day, two digits, with the same blindness to reality.
The pattern is a gatekeeper for format, not for truth.
US and EU formats, and why they collide
Outside ISO you meet two local conventions, and they disagree on which number is the month:
- US MM/DD/YYYY:
^(0?[1-9]|1[0-2])/(0?[1-9]|[12]\d|3[01])/\d{4}$ - EU DD-MM-YYYY:
^(0?[1-9]|[12]\d|3[01])-(0?[1-9]|1[0-2])-\d{4}$
The collision is the famous 03/04/2026 accident. In the US it is March 4. In most of the world it is April 3. If your form silently assumes one and the user assumed the other, records get entered a month off and nobody notices until much later. This ambiguity alone is a strong argument for defaulting to ISO everywhere.
Why regex cannot catch real dates
This is the core argument, and it is non-negotiable. A regex works on characters, not on the Gregorian calendar. Consider:
2026-02-30— February never has 30 days, yet the pattern accepts it. The02and30are individually valid digit pairs.2026-13-01— month 13 does not exist, but13is just two digits.- Leap years:
2024-02-29is real,2025-02-29is not. Deciding which requires modulo-4, modulo-100, modulo-400 arithmetic that no regex expresses without becoming a monstrous alternation.
You can tighten the month to 0[1-9]|1[0-2] and the day to 0[1-9]|[12]\d|3[01], but that still happily admits April 31 and every February 29 in a non-leap year. The shape is bounded; the truth is not.
How far you can push the regex
If you absolutely must reject the worst offenders without a parser, you can hardcode day ceilings per month and special-case February:
^(?:\d{4}-(?:0[13-9]|1[0-2])-(?:0[1-9]|[12]\d|30)|...)$
The honest cost is that the pattern balloons to dozens of alternations, it still needs a leap-year exception bolted on as yet more alternation, and it becomes unmaintainable and easy to get wrong. Every hour spent perfecting it is an hour not spent calling strptime.
Common mistakes and the fix
| Mistake | Symptom | Fix |
|---|---|---|
| \d{2}-\d{2}-\d{4} | Cannot tell month from day; US/EU clash | Pin the order explicitly, prefer ISO YYYY-MM-DD |
| ^....-..-..$ | Accepts letters and symbols | Use \d, not dots, for numeric fields |
| [1-31] for the day | Character class is wrong; means 1, 2, or 3 | Use (0?[1-9]|[12]\d|3[01]) |
| No anchor | Matches a date buried in other text | Keep ^ and $ |
What to use instead
Hand the date to a real parser, which knows the calendar. In Python:
from datetime import datetime
def parse_date(s):
d = datetime.strptime(s, "%Y-%m-%d")
# guard against 2026-02-30 being normalized to March 2
if d.strftime("%Y-%m-%d") != s:
raise ValueError("date does not exist")
return d
In JavaScript, parse then read back and compare, because new Date('2026-02-30') silently rolls over to March:
function parseDate(s) {
const d = new Date(s + "T00:00:00");
if (d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0") !== s)
throw new Error("date does not exist");
return d;
}
The parser catches what the regex cannot: that the 30th of February is fiction.
Generating versus parsing
If you are producing dates rather than checking user input, do not build the string by concatenation and a regex. Format with the language's date formatter so the separator and zero-padding are always correct, then store it in an unambiguous format (UTC, ISO) and only localize at the display layer. The regex's only honest role remains a cheap pre-filter that rejects obviously malformed input before the parser does the real work. When you are tempted to add yet another branch to your date pattern, ask whether the parser already does it in one line — almost always, it does.
Time zones and full ISO 8601
Dates often grow a time. The full ISO 8601 form looks like 2026-02-28T14:30:00Z or 2026-02-28T14:30:00+05:00. A regex for that is even longer and even less trustworthy around leap seconds and mixed offsets. Again, parse it: every modern language has an ISO datetime parser (datetime.fromisoformat, new Date with care, or a library like Luxon). Validate with a parser, format with a parser, and let the regex retire to the one job it is good at — checking the rough shape before you bother the parser at all.