Regex for an ISO 8601 date: YYYY-MM-DD
Two tested patterns for the calendar date form used by ISO 8601 and by the RFC 3339 internet profile: one that checks the shape and the month and day ranges, and one that also knows how many days each month has, including leap years.
Shape and ranges
Rule: A real calendar date written YYYY-MM-DD (RFC 3339 full-date: 4-digit year, month 01-12, day within the length of that month).
/^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ Open in tester Loads the pattern with sample text, JavaScript flavor.
Parts of the pattern
^ … $- Anchors: the whole string must be the date, nothing before or after.
[0-9]{4}- Exactly four ASCII digits for the year (RFC 3339: date-fullyear = 4DIGIT). [0-9] is used instead of \d so that Unicode-aware flavors do not accept other scripts' digits.
-(?:0[1-9]|1[0-2])- A hyphen, then month 01 to 09 or 10 to 12 (date-month = 2DIGIT, 01-12).
-(?:0[1-9]|[12][0-9]|3[01])- A hyphen, then day 01-09, 10-29 or 30-31. This is the 01-31 upper bound only; the pattern does not know which month it is in.
Test cases
12 of 15 rows agree with the rule. The other 3 are known limits of this pattern, explained in their notes.
| Input | Rule says | Pattern says | Result | Note |
|---|---|---|---|---|
2026-10-02 | valid | match | pass | Plain date. |
2024-02-29 | valid | match | pass | 2024 is divisible by 4 and not by 100, so it is a leap year. |
0001-01-01 | valid | match | pass | RFC 3339 allows any four-digit year. |
2026-12-31 | valid | match | pass | Last day of the year. |
2026-13-01 | invalid | no match | pass | Month 13 does not exist. |
2026-00-10 | invalid | no match | pass | Month 00 does not exist. |
2026-10-32 | invalid | no match | pass | No month has 32 days. |
2026-10-00 | invalid | no match | pass | Day 00 does not exist. |
2026-1-2 | invalid | no match | pass | Month and day must be two digits. |
20261002 | invalid | no match | pass | The compact form without hyphens is not the RFC 3339 syntax. |
2026-10-02\n | invalid | no match | pass | A trailing newline. JavaScript's $ does not match before it; PCRE2, Python and Java's $ do (see flavor notes). |
␣2026-10-02 | invalid | no match | pass | Leading space. |
2026-02-30 | invalid | match | false positive | KNOWN FALSE POSITIVE: February never has 30 days. |
2025-02-29 | invalid | match | false positive | KNOWN FALSE POSITIVE: 2025 is not a leap year. |
2026-04-31 | invalid | match | false positive | KNOWN FALSE POSITIVE: April has 30 days. |
Calendar-aware (months and leap years)
Rule: A real calendar date written YYYY-MM-DD in the proleptic Gregorian calendar (RFC 3339: February has 29 days in a year divisible by 4, except centuries not divisible by 400).
/^(?:[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)$/ Open in tester Loads the pattern with sample text, JavaScript flavor.
Parts of the pattern
[0-9]{4}-(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])- Months with 31 days (01, 03, 05, 07, 08, 10, 12): days 01-31.
[0-9]{4}-(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)- Months with 30 days (04, 06, 09, 11): days 01-30.
[0-9]{4}-02-(?:0[1-9]|1[0-9]|2[0-8])- February, days 01-28, in any year.
[0-9]{2}(?:0[48]|[2468][048]|[13579][26])-02-29- February 29 when the last two digits of the year form a multiple of 4 other than 00: 04 and 08; 20, 24, 28, 40 … 88 (even tens digit, last digit 0, 4 or 8); 12, 16, 32 … 96 (odd tens digit, last digit 2 or 6).
(?:[02468][048]|[13579][26])00-02-29- February 29 in a century year (…00) when the first two digits are a multiple of 4, that is, the year is divisible by 400: 0000, 0400, 0800, 1200, 1600, 2000, 2400 …
Test cases
13 of 13 rows agree with the rule.
| Input | Rule says | Pattern says | Result | Note |
|---|---|---|---|---|
2024-02-29 | valid | match | pass | Divisible by 4, not a century: leap. |
2025-02-29 | invalid | no match | pass | Not divisible by 4. |
2000-02-29 | valid | match | pass | Century divisible by 400: leap. |
1900-02-29 | invalid | no match | pass | Century not divisible by 400: not leap. |
2100-02-29 | invalid | no match | pass | 2100 is a century not divisible by 400. |
2026-02-30 | invalid | no match | pass | February never has 30 days. |
2026-02-28 | valid | match | pass | Last day of a common February. |
2026-04-30 | valid | match | pass | April has 30 days. |
2026-04-31 | invalid | no match | pass | April has 30 days. |
2026-06-31 | invalid | no match | pass | June has 30 days. |
2026-12-31 | valid | match | pass | December has 31 days. |
2026-13-01 | invalid | no match | pass | Month 13 does not exist. |
0000-02-29 | valid | match | pass | Year 0000 is divisible by 400. RFC 3339 allows it as a four-digit year. |
How it works
RFC 3339 defines the date part as full-date = date-fullyear "-" date-month "-" date-mday, with a four-digit year, a month of 01-12 and a day of 01-28, 01-29, 01-30 or 01-31 depending on the month and year. It gives a table of the maximum day for each month and says February has 29 days in a leap year, and it gives the leap-year rule: divisible by four, except that a century year must also be divisible by four hundred.
The first pattern implements everything in that grammar that a regular expression can state without arithmetic: the shape, the month range, and the 01-31 day range. It cannot connect the day to the month, so 2026-02-30 slips through. The second pattern spells the connection out: it has one branch per group of months, and it handles the leap day with two extra branches that test the year by its last two digits.
The leap-year branches are long because a regular expression cannot divide. A year is a multiple of 4 exactly when its last two digits are one of 00, 04, 08, 12 … 96. The patterns write those as two small character-class pairs (even tens digit with a last digit of 0, 4 or 8; odd tens digit with a last digit of 2 or 6). Century years need the first two digits to be a multiple of 4 as well. Because the structure is so mechanical, the test suite checks the pattern against an independent calendar function for every month and every day number from 01 to 31 in every year from 0000 to 2500.
Known false positives
- The first (ranges) pattern accepts impossible dates: 2026-02-30, 2025-02-29 and 2026-04-31 all pass. It is listed as a shape check for that reason.
- Neither pattern knows anything outside the calendar: a date in the far future, or a year such as 0000, passes if it is a valid Gregorian date. Whether your application accepts it is a business rule.
Known false negatives
- Both patterns reject the compact ISO 8601 form 20261002, week dates such as 2026-W40-5 and ordinal dates such as 2026-275. RFC 3339 is a deliberate subset of ISO 8601 and only the hyphenated calendar date is in it.
- Years with more than four digits, and negative years, are rejected. RFC 3339 specifies a four-digit year.
Flavor notes
- Trailing newline: JavaScript's $ matches only at the very end of the input, and so does Go's. In PCRE2 (by default), Python and Java a $ without the multiline option also matches just before a final newline, so "2026-10-02" followed by a line feed would pass. Use \z in PCRE2 and Go-compatible code, \Z or fullmatch() in Python, or Matcher.matches() in Java. The tests on this page run the PCRE2 version with \z.
- [0-9] is used instead of \d on purpose. In Python (for str patterns) \d matches any Unicode decimal digit, and in PCRE2 \d does too when the UCP option is on. [0-9] means the same everywhere.
- Both patterns use only plain groups, alternation and counted repetition, so they compile in JavaScript, PCRE2 and Go (which accepts only RE2 syntax). The test suite runs both on those three real engines. Python and Java are notes only on this site: nothing here is executed on them.
Why not regex here?
If you only need to know whether a date is real, parse it instead. In JavaScript, new Date("2026-02-30") does not reliably give you an error: MDN says a string can have in-bounds components yet not be a real date (its example is "February 30") and that implementations behave inconsistently in that case; we observed Node 20 turn new Date("2026-02-30") into 2 March 2026. Use a date library or the Temporal API when it is available, or split the string and check the day against the month length in code, which is shorter than the calendar-aware pattern and easier to read.
Use the ranges pattern when you want a cheap early filter (for example, in a form field) and will parse the value properly afterwards.
How to use
- Copy the pattern for the variant that fits your rule. Variants differ in strictness, and the rule line says exactly what each accepts.
- Check how it is anchored: the patterns use
^and$to test a whole string. To find the same thing inside longer text, remove the anchors (and add word-boundary or lookaround checks) and re-run the cases. - If your language is not JavaScript, read Flavor notes and change
$to\zor use a full-match function. - Open it in the tester to see the explanation of each token and try your own inputs.
Worked examples
Validate a form field, then parse
Use /^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ as the first gate, then split on "-" and check the day against the length of the month before you store the value.
Find ISO dates inside text
Drop the anchors and add \b word boundaries: \b[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])\b. Open the tester with the link above to try it on a paragraph.
Limits & gotchas
- The patterns test text only. They do not check that the date is acceptable for your application (a birth date in the future, a booking in the past).
- The calendar-aware pattern is about 250 characters long. That is the cost of doing arithmetic with character classes; if you need more rules, switch to code.
FAQ
Does this regex accept dates with a time, like 2026-10-02T10:00:00Z?
No. Both patterns are anchored and match only the date part. RFC 3339 defines a separate full-time and a date-time that joins them with T; a pattern for the whole timestamp would be a different, longer pattern.
Why not just use \d instead of [0-9]?
In JavaScript \d is [0-9], but in Python str patterns it matches any Unicode decimal digit, and in PCRE2 with UCP set it matches \p{Nd}. Writing [0-9] gives the same meaning in every flavor.
Is the leap-year logic the same as RFC 3339's?
Yes. RFC 3339 defines a leap year as one divisible by four, except that a century year must also be divisible by four hundred. The calendar-aware pattern encodes exactly that and is tested against an independent implementation of the same rule for the years 0000 to 2500.
Why does the first pattern accept 2026-02-30?
A regular expression cannot relate the day to the month unless you write out each month, which is what the second pattern does. The first one is kept as a short shape check and the table marks its false positives.
Sources
- IETF: RFC 3339: Date and Time on the Internet Used for: full-date grammar, date-mday limits by month, leap-year rule, ISO 8601 profile.
- MDN: Date Used for: Date-time string format; implementations are not required to return Invalid Date for out-of-bounds date components.
- MDN: Input boundary assertion: ^, $ Used for: ^ and $ are the start and end of input, or of each line with the m flag.
- PCRE2: pcre2pattern Used for: Syntax and semantics: groups, named groups, lookbehind rules, atomic groups, possessive quantifiers, \d \s \w with and without UCP, dollar and newline handling, \A \Z \z.
- Python docs: re: Regular expression operations (3.13) Used for: Syntax, (?P<name>), atomic groups and possessive quantifiers (3.11+), fixed-length lookbehind, Unicode \d \s \w, $ before trailing newline, \A \Z, re.sub replacement syntax, inline flags at start only (3.11+).
- Oracle (Java SE 21): java.util.regex.Pattern Used for: Construct table, \d \s \w without UNICODE_CHARACTER_CLASS, possessive and atomic constructs, named groups, line terminators and $, \A \Z \z.
- Go: regexp/syntax Used for: Full syntax table; no lookaround or backreferences; \d \s \w are ASCII-only; $ is \z; (?P<name>) and (?<name>); repetition limit 1000; \A and \z.
- MDN: Regular expressions (reference) Used for: List of flags (d g i m s u v y), the groups of syntax, and which syntaxes are assertions.
Every document above was opened and read on 2026-10-02. Documentation changes; if a page here disagrees with the current docs, trust the docs and tell us.