Regex for an ISO date (YYYY-MM-DD)

/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/

/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/ checks the ISO 8601 calendar date format: a four-digit year, a month from 01 to 12 and a day from 01 to 31, each zero-padded and joined by hyphens.

What it accepts

The tester below checks one date per line with g and m; for a single value, drop both flags.

2026-10-14    matches
1999-12-31    matches
2024-02-29    matches: 2024 is a leap year
2026-13-01    no: month 13
2026-00-10    no: month 00
2026-1-5      no: not zero-padded
26-10-14      no: two-digit year
2026-02-31    matches, though February has no 31st

The part a regex can’t do

The day range doesn’t depend on the month, so 2026-02-31 and 2026-04-31 pass. A regex that knows month lengths and leap years exists, but it runs to a couple of hundred characters, and a calendar is easier to ask than to spell out.

new Date() isn’t that calendar on its own. In Chrome and Node, new Date('2026-02-31') doesn’t fail: it rolls over to March 3. Parse the date, write it back out and compare:

const ISO_DATE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;

function isRealDate(text) {
  if (!ISO_DATE.test(text)) return false;
  const date = new Date(`${text}T00:00:00Z`);
  return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === text;
}

isRealDate('2024-02-29'); // true
isRealDate('2026-02-29'); // false: it came back as 2026-03-01

The T00:00:00Z pins the date to midnight UTC, so the visitor’s timezone can’t move it to another day.

Dates with a time

For a full timestamp such as 2026-10-14T09:30:00Z or 2026-10-14T09:30:00.125+02:00, as RFC 3339 and JSON APIs write them, extend the pattern:

^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$

That one checks only the shape of each field; run the same round-trip test on the date part before you trust it.

Try it in the Regex Tester

Flags

4 matches

Matches

#MatchAtGroups
12026-10-140–10none
21999-12-3111–21none
32024-02-2922–32none
42026-02-3173–83none

What the pattern means

  • ^The start of a line
  • \d{4}A digit (0–9), exactly 4 times
  • -The character "-"
  • (?:0[1-9]|1[0-2])Non-capturing group, either of 2 alternatives
    • 0[1-9]First alternative, in order
      • 0The character "0"
      • [1-9]One of: 1–9
    • 1[0-2]Second alternative, in order
      • 1The character "1"
      • [0-2]One of: 0–2
  • -The character "-"
  • (?:0[1-9]|[12]\d|3[01])Non-capturing group, one of 3 alternatives
    • 0[1-9]First alternative, in order
      • 0The character "0"
      • [1-9]One of: 1–9
    • [12]\dSecond alternative, in order
      • [12]One of: "1" or "2"
        • 1The character "1"
        • 2The character "2"
      • \dA digit (0–9)
    • 3[01]Third alternative, in order
      • 3The character "3"
      • [01]One of: "0" or "1"
        • 0The character "0"
        • 1The character "1"
  • $The end of a line
  • g global: Find every match, not just the first. exec() and test() carry on from lastIndex.
  • m multiline: ^ and $ match at the start and end of each line, not just of the whole string.

Runs in your browser. Nothing you enter is sent anywhere.