日期格式校验正则表达式:从基础匹配到闰年判断的完整指南
2026/9/23 12:53:40 网站建设 项目流程

1. 日期格式校验:需求拆解与整体思路

日期格式的正则表达式,这个需求在开发中出现的频率高得惊人。表单里的生日、合同里的签署日期、日志里的时间戳、数据库里的入库时间,只要是做过后端接口或者写过前端校验的人,几乎都碰到过“帮我校验一下这个日期格式”的需求。我见过很多新人上来就写一个\d{4}-\d{2}-\d{2},然后发现这个正则能匹配2024-01-01,也能匹配9999-99-99,更别说2024-02-30这种根本不存在的“日期”了。

在动手写正则之前,必须先把需求拆清楚。日期正则其实分三个层次,很多人没想明白自己要的是哪一个,导致要么写得过于宽松,要么写得过于复杂:

  • 第一层,只匹配“看起来像日期”的字符串。比如从一段文本里把2024-06-01这种形状的内容提取出来,不关心2月有没有30号。这适合日志提取、爬虫清洗这类对准确性要求不高的场景。
  • 第二层,校验格式并且检查月日范围。比如月份必须是01到12,日期必须是01到31。这适合绝大多数表单校验场景,能挡住大多数无意义的输入。
  • 第三层,校验日期是否真实存在。比如2023-02-29不合法因为2023年不是闰年,2024-02-30不合法因为2月没有30号。这个级别写起来最费劲,但确实在一些数据严苛的场景下有用。

明确了这三个层次,再写正则就不会盲目了。这篇文章我会把三层都写出来,从简单到复杂逐步拆解每一段的含义,再带入JavaScript、Python、Java、C#这些常见语言里说到说到实践经验和坑。

2. 基础版:年月日匹配的写法与原理

2.1 最简单的写法为什么不够用

先看大多数人第一反应写出来的版本:

\d{4}-\d{2}-\d{2}

这个正则的含义是:4位数字、短横线、2位数字、短横线、2位数字。在匹配2024-06-01的时候没问题,但问题也很明显:

  • 它会匹配9999-99-99,因为99作为月份和日期,在正则眼里只是“两位数字”,它不知道月份最大是12。
  • 它会匹配2024-1-1吗?不会,因为\d{2}要求正好两位数字。如果你的业务允许用户输入不补零的日期,比如2024-6-1,那这个正则就直接误判了。
  • 如果目标字符串是“今天是2024-06-01,明天是2024-06-02”,不加边界符的正则会匹配出两段日期,这在某些场景下可能是你要的,但如果你想校验“整个字符串必须是一个合法的日期”,就需要用^$把字符串两头锁住。

所以基础版至少要解决两件事:限制月份和日期的取值范围,加上边界限定。

2.2 限制月份和日期的取值范围

把月份限制在01到12,正则写法是这样的:

(0[1-9]|1[0-2])

这里用了两个分支:0[1-9]匹配01到09,1[0-2]匹配10到12。如果业务允许不补零,也就是1到12,那写成(0?[1-9]|1[0-2]),多了一个0?表示前面可以有零也可以没有。很多团队会统一要求日期必须补零,这样数据看起来整齐,正则也简单些。

日期限制在01到31,写法是:

(0[1-9]|[12][0-9]|3[01])

三个分支分别对应01到09、10到29、30到31。注意[12][0-9]不能写成[12]\d,因为\d在部分语言的正则引擎里会匹配到全角数字,比如12这种字符,而[0-9]只匹配ASCII的半角数字。安全起见,日期类正则我统一建议用[0-9]而不是\d

合起来,一个基础的日期校验正则就是:

^(19|20)[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$

开头(19|20)[0-9]{2}是把年份限制在1900到2099之间,如果你要放宽,可以直接用[0-9]{4},但要注意0000这种年份在大多数业务场景里是没有意义的。这里就引出一个问题:年份的范围到底是业务决定的,还是正则决定的?我的建议是正则只做格式层面的粗筛,真正的业务范围(比如出生年份不能超过当前年份)交给代码逻辑判断,不要硬塞进正则是。

2.3 处理多种分隔符与捕获组引用

很多项目不止用2024-06-01这一种格式,还会遇到2024/06/012024.06.01。处理思路很简单,把分隔符写成字符组:

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

这里有个小知识点:([-/.] )外面的括号是捕获组,把匹配到的分隔符存起来了,后面的\1是反向引用,要求第二个分隔符和第一个完全一样。这样才能保证2024-06/01这种“混搭风格”不会被误判为合法日期。

要特别留意的是,一旦你加了别的捕获组,\1的编号就会变。比如改成^([0-9]{4})([-/.])(0[1-9]|1[0-2])\2(0[1-9]|[12][0-9]|3[01])$,此时第1组是年份,第2组才是分隔符,所以反向引用要写\2而不是\1。这是初学者最容易踩的坑,后面在实战环节我会再提一次。

如果你不想数括号编号,也可以用非捕获组(?:...)把不需要引用的分组包起来,这样保持后面的编号不变。正则的可读性本来就一般,能给后人留一点善意就留一点。

3. 进阶版:月份天数与闰年规则的正则表达

3.1 为什么2月30号这种“假日期”会漏过去

上面写的基础版正则,能挡住13月、32号这种明显错误,但挡不住2月30号、4月31号、6月31号这种“格式合法但现实不存在”的日期。原因是(0[1-9]|[12][0-9]|3[01])里的3[01]允许了31号,而正则并不知道当前月份是哪个月、这个月到底有几天。

这其实是正则表达式的天然短板:它只做模式匹配,不具备“查日历”的能力。但如果我们非得用正则做,也不是完全做不到,思路就是把每个月的情况单独写成分支,让正则“记住”哪个月对应多少天。这样的正则虽然长,但每个分支的逻辑是清晰可读的。

3.2 大月、小月、二月的分支拆解

我们先不碰闰年,把平年的情况梳理一下:

  • 大月,31天:1月、3月、5月、7月、8月、10月、12月。正则写法为(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])
  • 小月,30天:4月、6月、9月、11月。正则写法为(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)
  • 2月平年,28天:正则写法为02-(?:0[1-9]|1[0-9]|2[0-8])

把这三段用|合起来,再套上年份前缀,就是一个能区分大小月的平年日期正则:

^[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]))$

你可以验证一下:2024-04-31在这个正则下是不匹配的,因为月份04走的是小月分支,日期只能是01到30。2024-02-28能匹配,但2024-02-29不匹配,因为我们还没处理闰年。

3.3 闰年判断的本质与正则模拟

闰年的规则说起来很简单:能被4整除但不能被100整除的年份是闰年;能被400整除的年份也是闰年。用代码写就是:

(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0

但正则没有取模运算,它只能通过数字的规律来模拟这个逻辑。这里有个小学数学知识:一个数能不能被4整除,只看最后两位组成的数字能不能被4整除。比如2024能不能被4整除,看24就行,24能被4整除,所以2024就能被4整除。同理,一个数能不能被100整除,看后两位是不是00。

于是闰年判断在正则里可以拆成两种情况:

  • 非世纪闰年:年份后两位能被4整除且后两位不是00。这个条件等价于后两位匹配(?:0[48]|[2468][048]|[13579][26]),前面再补上任意两位数字。
  • 世纪闰年:年份后两位是00,且前两位能被4整除。也就是匹配(?:0[48]|[2468][048]|[13579][26])00

整个闰年2月29日的正则分支可以写成:

(?:(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)

这里面的[2468][048][13579][26]是什么意思?逐位拆开看:能被4整除的两位数,个位只能是0、4、8或者2、6;十位是奇数时,个位只能是2或6;十位是偶数时,个位只能是0、4、8。所以[2468][048]覆盖了十位为2/4/6/8的情况,[13579][26]覆盖了十位为1/3/5/7/9的情况。再加上0[48]覆盖04、08,三类合起来就是所有能被4整除的两位数字。

这种写法说实话不好读,但它确实是正则模拟“能被4整除”的经典写法。我自己更推荐把闰年判断放在正则之外,因为正则里塞这种逻辑,后续维护的人大概率要花半小时才能反应过来。

4. 完整版:具备日期合法性校验的正则封装

4.1 一个相对完整的日期正则

把前面几节的内容拼起来,再加上闰年分支,就可以得到一个能判断“日期真实存在”的正则。这里给一版比较常用的写法:

^(?:(?!0000)[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])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$

这段正则看起来很长,但其实结构是清晰的:最外层是一个大分组,左边是平年分支,右边是闰年2月29日的分支。

我先用几个例子验证一下:

输入是否匹配原因
2024-06-01匹配正常的闰年日期
2024-02-29匹配2024是闰年,2月有29日
2023-02-29不匹配2023不是闰年
2024-04-31不匹配4月只有30天
2024-13-01不匹配13月不存在
1900-02-29不匹配1900能被4整除但不能被100整除,不是闰年
2000-02-29匹配2000能被400整除,是闰年

测试下来这版逻辑是通的。但我也要说清楚,这段正则有一个缺陷:年份范围没有限制,(?!0000)只能排除0000年,其他任何四位数都能走平年分支或闰年分支。如果业务上只允许1900到2099,可以把[0-9]{4}替换成(?:19|20)[0-9]{2},其他部分保持不变。

4.2 逐段拆解完整正则的构成

我习惯把这种长正则拆成三块来看:

第一块是年份部分:(?!0000)[0-9]{4}(?!0000)是负向前瞻,表示接下来的四位不能是0000。这里用前瞻而不是直接写[1-9][0-9]{3},是因为后者会排除掉0123这种以0开头的年份,而用前瞻只排除“全零”这一个特殊情况,更通用。

第二块是平年的月日部分:(?: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])对应平年2月。三个分支用|连接,外加一个非捕获组(?:...)包起来。

第三块是闰年2月29日的部分:(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29。前半段是普通闰年,后半段是世纪闰年。

每次要排查这种正则的时候,我建议在在线正则工具里打开忽略大小写的开关,把每段用注释分隔来测试。比如regex101这个工具,它支持在正则里写注释,遇到长正则会比肉眼看舒服得多。

4.3 扩展:日期时间格式与带补零要求

实际项目中我们更常遇到的是日期时间格式,比如2024-06-01 12:30:00或者ISO格式的2024-06-01T12:30:00.123Z。日期时间格式的正则,可以在日期正则后面追加时间部分:

^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])[ T]([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$

这里时间部分同样要注意:

  • ([01][0-9]|2[0-3])是24小时制的小时,01到09、10到19、20到23。
  • [0-5][0-9]是分钟和秒,因为分钟和秒最大就是59。
  • [ T]表示中间的空格或字母T,ISO 8601标准里常用T来连接日期和时间。

如果需要毫秒,可以在秒后面加(\.[0-9]{1,3})?。时区部分Z或者+08:00这种,正则写起来比较啰嗦,建议遇到带时区的时间直接用专门的日期解析库去处理,不要硬刚正则。日期正则的核心价值是“快速筛选出形状正确的字符串”,而不是替代一个完整的时间解析器。

5. 实战环节:不同语言里的写法与踩坑

5.1 JavaScript:test、match与new Date的坑

JavaScript里的正则字面量方式写起来最自然:

const datePattern = /^(?:(?!0000)[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])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$/; datePattern.test("2024-02-29"); // true datePattern.test("2023-02-29"); // false

在JavaScript里有一个非常经典的坑:new Date("2024-02-30")并不会返回“无效日期”,而是会静默地把2月30日解析成3月1日。所以如果你用正则校验通过之后,又用new Date()做了二次校验,很容易出现“正则认为不合法,new Date认为合法”的错觉。正确的做法是校验完再对比一下日期字符串是否保持不变:

function isValidDate(str) { const pattern = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; if (!pattern.test(str)) return false; const date = new Date(str); return date.toISOString().slice(0, 10) === str; }

这里toISOString()会把日期转成UTC时区的字符串,所以本地时区是东八区也不会影响结果,因为new Date("2024-06-01")看到的是UTC时间的零点。这个方法在只需要验证年月日的时候特别管用。

5.2 Python:re.match与\d的坑

Python里用re模块就能处理:

import re pattern = r"^(?:(?!0000)[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])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$" re.match(pattern, "2024-02-29") # 返回匹配对象 re.match(pattern, "2023-02-29") # 返回 None

这里要提醒一个和\d相关的坑。Python的re模块默认情况下\d能匹配Unicode中的数字字符,比如阿拉伯文数字、全角数字“123”,如果你写\d{4},用户输入“2024-06-01”也可能被匹配上。所以在日期这种对字符要求严格的场景里,我建议一律用[0-9]

另外Python里re.matchre.search有区别:match从字符串开头匹配,所以配合^前缀作用类似;search是扫描整个字符串,如果你用search并且正则是^\d{4}$,那其实match更合适。我习惯的做法是正则里明确写^$,这样用search也能达到整串匹配的效果。

5.3 Java与C#:字符串转义与RegexOptions

Java里写正则需要特别注意字符串转义,反斜杠在Java字符串里必须写成\\

import java.util.regex.Pattern; String regex = "^(?:(?!0000)[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])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$"; Pattern pattern = Pattern.compile(regex); System.out.println(pattern.matcher("2024-02-29").matches()); // true

Java的Matcher.matches()方法要求整个字符串完全匹配,所以写不写^$效果一样,但为了统一风格我还是会在正则里写上。

C#的写法跟Java很接近,区别是C#的正则更容易踩到性能坑。如果一段日期正则在循环里被反复调用,建议使用RegexOptions.Compiled来编译正则:

using System.Text.RegularExpressions; var pattern = new Regex(@"^(?:(?!0000)[0-9]{4}-...$)", RegexOptions.Compiled); pattern.IsMatch("2024-02-29"); // true

Compiled选项首次匹配会花更多时间做编译,但后续匹配会快很多。如果只是零星几次校验,就别加这个选项了,否则反而更慢。

5.4 从文本中提取日期:先粗筛再精洗

很多场景不需要“整个字符串必须是一个日期”,而是要把一段文本里的日期抠出来。比如从爬虫抓取的网页正文里提取发布时间,从客服聊天记录里提取日期。这种提取需求,我建议用宽松正则会更快更好维护:

import re text = "活动时间:2024-06-01至2024/06/05,请提前一天报名。" dates = re.findall(r"[0-9]{4}[-/.](?:0?[1-9]|1[0-2])[-/.](?:0?[1-9]|[12][0-9]|3[01])", text) print(dates) # ['2024-06-01', '2024/06/05']

注意这里没有加^$,日期是从文本中间找到的。而且我故意把月份和日期的正则写成了0?[1-9]这种允许不补零的版本,因为真实场景中用户可能写“2024-6-1”而不是标准的“2024-06-01”。提取出来之后,再统一做一轮标准化转换,用日期库或者手动split成三段,转成规范的ISO格式。正则在这个环节只需要做“找出来”的粗活,不要让它连“判断是否真实存在”也一起做了。

热搜词里有个“提取出中间的数字及#符号后的字符串”,本质上就是这种“先粗提取再清洗”的思路。先用一个宽松的正则把命中的文本段切出来,然后再用replace或者第二个正则去掉干扰符号,比企图写一个一步到位的超大正则要可靠得多。

6. 常见问题与排查技巧实录

6.1 正则只匹配了日期的一部分而不是完整日期

这是很多人第一次调试日期正则时的困惑:明明写的是2024-06-01,但正则从一段较长的数字串中间就把2024-06或者06-01匹配出来了。原因就是缺少边界符,正则引擎只要在字符串里找到一个位置能从头开始匹配到“某个子串”就算成功。

解决办法:

  • 希望整个字符串就是日期,那就必须在开头加^、结尾加$
  • 希望从文本里提取所有日期,不要加^$,但可以考虑加“前后不能是数字”这种约束,用负向前瞻和负向后顾,比如(?<!\d)(?!\d),防止匹配到一段连续数字中间的一部分。
  • 正则的$在JavaScript里如果字符串末尾有换行符,可能匹配到换行前的位置,严谨起见可以用\z(PCRE)或者对字符串先做trim()再校验。

6.2 用\d匹配到了全角数字或者意外字符

不同语言里\d的含义不一样。JavaScript的\d[0-9]的简写,行为还算规矩;Python的re模块\d默认匹配Unicode数字,范围包括阿拉伯文数字、全角数字等;Java的\d默认是\p{Digit},也包含一些Unicode范围。结果就是,你以为只匹配半角数字,实际把各种“看起来像数字”的字符都放进来了。

排查技巧很简单:把正则里的\d全部替换成[0-9],再重新跑测试用例。日期正则的字符集越明确越安全,不要偷懒写\d。这个习惯养成了,以后写其他数字类正则也能少踩很多坑。

6.3 存在性能风险的长正则

有些日期正则为了追求“一步到位”,会把月份、日期、闰年判断全都堆在一起,嵌套很多分组。这种正则在短字符串上一般没问题,但如果被用在大量数据的扫描场景,并且正则里出现了(a+)+这类嵌套量词,就有可能触发灾难性回溯,导致程序卡死。

日期正则里最常见的性能隐患是把[0-9]{4}写成[0-9]+,然后在后面又加了-和更多字符。遇到匹配失败的长文本时,正则会不断尝试各种拆分方式,消耗指数级的时间。我的建议是:

  • 能用{n}精确表示次数就不用+
  • 尽量使用非捕获组(?:...),避免不必要的回溯记录。
  • 如果在日志清洗这类高性能要求的场景使用,先做一次简单的indexOf('-')之类的预筛,再跑正则,性能会好很多。

6.4 正则校验通过了,但日期解析函数却报错

正则毕竟只能做模式匹配,它无法理解“2024年2月29日是存在的,但2023年2月29日不存在”这种语义。有时候正则写得太宽松,比如只校验到月日范围这层,2024-02-30就会通过校验。如果后端恰好用了Date.parse或者datetime.strptime这类解析函数,轻则返回一个“被修正”的日期,重则直接抛异常。

我的处理习惯是:

  • 表单提交这种用户入口,用中间层级的日期正则做格式校验,然后用语言自带的日期解析做合法性校验。比如Python里用datetime.strptime(str, "%Y-%m-%d"),它天然会拒绝不存在的日期。
  • 如果坚持要用正则做完整合法性校验,那就要付出维护成本,并且把文章第4节那种长正则写清楚注释,否则三个月后的自己看到这段正则也会一头雾水。

6.5 常见问题速查表

现象原因解决办法
2023-02-29被匹配正则没有处理闰年分支加入闰年判断分组,或在代码里用日期函数二次校验
2024-04-31被匹配正则没有区分大月小月将月份拆成31天、30天、2月三组分别匹配
2024/06-01被匹配分隔符没有用反向引用保持约束([-/.] )捕获分隔符并通过\1\2引用
12:60:00被匹配分秒范围写错成了[0-9]{2}改成[0-5][0-9]
2024-1-1不被匹配正则要求必须补零在月份和日期前加0?支持不补零输入
文本中间一段被误匹配缺少边界条件^$,或用(?<!\d)(?!\d)限定前后边界

这份表格里的每一条,我基本都在真实项目里遇到过。尤其是第2和第3条,几乎每个写过日期正则的同事都会中招一次。排查的核心思路其实很简单:不要急着改正则,先用一组已知合法的日期和一组已知非法的日期,把当前正则跑一遍,看它到底卡在哪一层。能定位到具体是月份分支还是日期分支的问题,剩下的就是照着规则改。

写在最后

日期正则写过几轮之后,我个人的习惯是分场景选择正则的复杂程度:对用户交互场景,用“格式校验+日期函数二次校验”的组合,正则不会难维护,数据也不会出错;对日志或爬虫提取场景,用宽松正则会省很多事,提完之后再统一清洗;只有极少数不允许引入依赖、又必须严格校验的场景,才会搬出那种带闰年分支的长正则。

如果你第一次接触完整版闰年正则,被[2468][048]|0[48]这种写法搞晕了,我的建议是先别急着背,拿几个具体年份在在线工具里跑一下,看到2024能匹配、1900不能匹配,就自然理解它到底在干什么了。想真正把正则学扎实,最好的方式就是拆解自己遇到的每一个长正则,弄清楚每一段缩写的意图,下次再遇到类似需求时,手比脑子快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询