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.

Test cases for Shape and ranges
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.

Test cases for Calendar-aware (months and leap years)
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

  1. Copy the pattern for the variant that fits your rule. Variants differ in strictness, and the rule line says exactly what each accepts.
  2. 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.
  3. If your language is not JavaScript, read Flavor notes and change $ to \z or use a full-match function.
  4. 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

  1. 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.
  2. MDN: Date Used for: Date-time string format; implementations are not required to return Invalid Date for out-of-bounds date components.
  3. MDN: Input boundary assertion: ^, $ Used for: ^ and $ are the start and end of input, or of each line with the m flag.
  4. 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.
  5. 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+).
  6. 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.
  7. 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.
  8. 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.