The practical regex for ISO 8601 dates (YYYY-MM-DD): ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$. It rejects month 13, day 32, and the all-zeros date. There are equivalent patterns for US-style MM/DD/YYYY, EU-style DD/MM/YYYY, relaxed forms that accept single-digit months, and a common ISO-style timestamp with time. Below I walk all of them, with runnable code in JavaScript, Python, and PHP, engine notes, common bugs, and calendar validation after the format check.
The compact patterns here do not reject 2025-02-30 or enforce leap years. A more complex regex can encode those rules, but a strict date parser is easier to maintain. Regex narrows the input to "date-shaped"; the parser confirms it is a real day.
The executable validators below use years 0001–9999. The short shape regex alone also accepts year 0000; whether that year is meaningful depends on the calendar convention and parser.
Quick reference
ISO 8601 date:
^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$
US (MM/DD/YYYY):
^(0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])\/\d{4}$
EU (DD/MM/YYYY):
^(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2])\/\d{4}$
Common ISO-style datetime shape with timezone:
^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$
Use one explicit input format. 09/10/2026 is ambiguous without knowing whether the first field is month or day. A date format regex should not guess the user's locale. For a timestamp, validate the offset, calendar date, and application-supported precision after the shape check; the example is not a complete implementation of every ISO 8601 representation.
ISO 8601 date (YYYY-MM-DD)
^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$
\d{4}is a 4-digit year. To restrict to a sensible range, use(19|20)\d{2}for 1900-2099.(0[1-9]|1[0-2])is the month: 01-09 or 10-12.(0[1-9]|[12][0-9]|3[01])is the day: 01-09, 10-29, or 30-31.
This pattern accepts 2025-02-30 and 2025-04-31 (days that do not exist in those months). Use a date parser for maintainable calendar validation.
US format (MM/DD/YYYY)
^(0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])\/\d{4}$
Same month and day sub-patterns, year on the right, separator is /. To allow - or . as alternative separators: replace \/ with [\/\-.] (and use the same separator both times by capturing it: ([\/\-.]) ... \1).
EU format (DD/MM/YYYY)
^(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2])\/\d{4}$
Same components as US, day-first instead of month-first. The classic ambiguity: 01/02/2025 could be January 2nd (US) or February 1st (EU). Always know which format your input is in before validating.
Relaxed: single-digit months and days
If the input might be 2025-2-9 (not zero-padded):
^\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[12][0-9]|3[01])$
The ? after the leading zero makes it optional, so both 02 and 2 match.
ISO 8601 datetime with time and timezone
This pattern covers the timestamp shape used by examples like 2025-10-29T14:32:07Z or 2025-10-29T14:32:07-05:00:
^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$
The added parts:
Tseparator between date and time.(?:[01]\d|2[0-3])for hour (00-23).:[0-5]\d:[0-5]\dfor minutes and seconds (00-59).(?:\.\d+)?for optional fractional seconds.(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)for the timezone: eitherZfor UTC or a+HH:MM/-HH:MMoffset.
Examples in JavaScript, Python, and PHP
JavaScript:
const isoDate = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/;
function isValidIsoDate(input) {
if (typeof input !== "string" || !isoDate.test(input) || input.startsWith("0000-")) return false;
// Belt-and-braces: ensure it parses as a real date too
const d = new Date(input);
return !isNaN(d.getTime()) && input === d.toISOString().slice(0, 10);
}
isValidIsoDate("2025-10-29"); // true
isValidIsoDate("2025-02-30"); // false (regex passes, Date check fails)
isValidIsoDate("2025-13-01"); // false (regex fails)Python:
import re
from datetime import datetime
ISO_DATE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$")
def is_valid_iso_date(value: str) -> bool:
if not ISO_DATE.fullmatch(value):
return False
try:
datetime.strptime(value, "%Y-%m-%d")
return True
except ValueError:
return False
is_valid_iso_date("2024-02-29") # True (leap year)
is_valid_iso_date("2023-02-29") # False (not a leap year)PHP:
function isValidIsoDate(string $value): bool {
if (str_starts_with($value, '0000-') || preg_match('/\A[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])\z/', $value) !== 1) {
return false;
}
$d = DateTime::createFromFormat('Y-m-d', $value);
return $d !== false && $d->format('Y-m-d') === $value;
}The pattern catches obvious format errors instantly; the date parser catches "valid format, impossible date" cases like February 30 or 2023-02-29.
Engine compatibility
Character classes and alternation are widely supported, but \d, delimiter escaping, and ending anchors differ across engines. Use [0-9] when the format requires ASCII digits, and a whole-string check before parsing.
| Engine | Practical patterns | Native date parser |
|---|---|---|
| JavaScript | Works | new Date(s) may normalize impossible dates; require a format round trip as shown above. |
Python (re) | Works | datetime.strptime(s, fmt). Raises ValueError. Or datetime.fromisoformat(s) for ISO. |
| PHP (PCRE) | Works | DateTime::createFromFormat('Y-m-d', $s) may normalize dates; compare the formatted result to the input. |
| Java | Works | LocalDate.parse(s) for ISO; DateTimeFormatter.ofPattern(fmt).parse(s) for custom. |
| .NET | Works | DateTime.TryParseExact(s, fmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d). |
| Go (RE2) | Works | time.Parse("2006-01-02", s). The reference time is a Go quirk. |
Rust (regex crate) | Works | chrono::NaiveDate::parse_from_str from the chrono crate. |
| Ruby | Works | Date.parse(s) (lenient) or Date.strptime(s, fmt) (strict). |
POSIX ERE (grep -E) | Works (replace \d with [0-9]) | None; use date -d for parsing |
I prefer fromisoformat in Python 3.11+ and LocalDate.parse in Java: they both reject 2025-02-30 automatically without needing the round-trip check.
Common mistakes
The bugs I see most often.
Trusting this short regex to validate the actual day. It only checks the listed field ranges. 2025-02-30 and 2025-04-31 match the regex but those days do not exist. Always pair the regex with a date parser if the input has to be a real day.
Two-digit years. Patterns like ^\d{2}-\d{2}-\d{2}$ accept 25-10-29 but you cannot tell if "25" is 1925 or 2025. Always require a 4-digit year unless you have a hard reason not to.
Hardcoded separators. A pattern with literal / rejects 2025-10-29 (hyphens). Either pick one format per input field, or use a character class [\/\-.] and capture it with a backreference to require both sides match.
Forgetting the anchors. \d{4}-\d{2}-\d{2} matches 2025-10-29extra because nothing pins the end. Always anchor for validation.
Locale-ambiguous parsing. 01/02/2025 could be January 2nd or February 1st depending on locale. Never accept this format from international users without an explicit locale signal.
Treating a passing regex as a parsed value. The regex returns true/false. It does NOT give you a usable Date object. Run the parser after the regex to get the actual date for arithmetic and comparison.
Calendar validation: leap years and days in each month
A sufficiently detailed regex can encode calendar rules, but the short patterns above intentionally leave these checks to a date parser:
- February 29 is only valid in years divisible by 4 (except century years not divisible by 400).
- February 30 and February 31 never exist.
- April, June, September, and November have 30 days; everything else has 31 except February.
To enforce these, run the regex first and then parse the string as a date in your language. If the parse succeeds and round-trips back to the same string, the date is real. This is the pattern shown in all three code examples above.
Test cases
| Input | ISO regex | After Date parse |
|---|---|---|
2025-10-29 | Match | Valid |
2024-02-29 | Match | Valid (leap year) |
2023-02-29 | Match | Invalid (not a leap year) |
2025-02-30 | Match | Invalid (never exists) |
2025-13-01 | No match | Not reached |
2025-00-15 | No match | Not reached |
2025-04-31 | Match | Invalid (April has 30 days) |
25-10-29 | No match | Not reached (2-digit year) |
FAQ
A complex regex can, but these short patterns only confirm that the input looks like a date (right separators, year/month/day in valid ranges) but cannot enforce that February 29 is only valid in leap years or that April has 30 days, not 31.
The pattern: regex narrows to "date-shaped", then a date parser confirms it is a real day. All three language examples in this article follow that pattern.
Use a character class for the separator: [\/\-.]. To require the same separator on both sides, capture it: ([\/\-.])(0[1-9]|1[0-2])\1(0[1-9]|[12][0-9]|3[01]) where \1 backreferences the first separator.
Be aware that 01.02.2025 is unambiguous (EU dot format) but 01/02/2025 is ambiguous between US and EU.
Add a T separator and the time/timezone parts: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$.
This matches 2025-10-29T14:32:07Z and 2025-10-29T14:32:07-05:00 but not the older YYYYMMDDTHHMMSS compact form.
Alternate the two patterns: ^((0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])|(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2]))\/\d{4}$.
But you cannot disambiguate them: 01/02/2025 matches both. If you accept either, you need an out-of-band signal (a locale, a form field saying which) to interpret the result.
Because regex day-validation only checks the 01-31 range; it does not know that February ends at 28 or 29. The bounded day part (0[1-9]|[12][0-9]|3[01]) happily accepts 30.
Validate with a date parser after the regex passes. new Date("2025-02-30") in JavaScript can normalize to a date in March; compare the formatted result back to the original input; strptime in Python raises ValueError. Parsing behavior differs, which is why the JavaScript example includes a round-trip check.
Year only: ^(19|20)\d{2}$. Month-year (e.g., credit card expiry): ^(0[1-9]|1[0-2])\/(?:[0-9]{2}|[0-9]{4})$ for "MM/YY" or "MM/YYYY".
For "YYYY-MM" use ^\d{4}-(0[1-9]|1[0-2])$. This is a reduced-precision calendar date, not an ordinal date.
Yes. The pattern in this article includes (?:\.\d+)? which allows an optional dot and one-or-more digits after the seconds. 2025-10-29T14:32:07.123Z and ...07.123456789Z both match.
If you need to cap the fractional precision (some databases reject more than 6 digits), tighten the quantifier: (?:\.\d{1,6})?.
See also
- How to Match Numbers with Regex: year, month, and day are all bounded-range numbers
- How to Match an Email Address with Regex: the cousin field on every signup form
- Regex Anchors: why
^and$matter so much for date validation - Regex Lookaheads and Lookbehinds: composing constraints like "must precede a time"
- Regex Capturing Groups and Backreferences: the
\1trick for "same separator on both sides" - Regex Cheat Sheet: the wider syntax and engine compatibility reference
External reference: the ISO 8601 standard summary covers the full date-and-time formats. Try the patterns at regex101.com with your own input strings.
Sources
Authoritative references this article was fact-checked against.
- ISO 8601 date and time format (Wikipedia summary)en.wikipedia.org
- Python datetime documentationdocs.python.org
- MDN: JavaScript Date referencedeveloper.mozilla.org





