☰
MySQL DATE_FORMAT() 函数详解:格式符、实战场景与避坑指南
2026/10/21 15:33:02 网站建设 项目流程

做后端开发这几年,我处理过的日期格式化需求少说也有上百个:运营要"昨天和上周同期的GMV对比图",报表要"按小时维度看接口调用趋势",日志要"把 2024-06-15 14:30:00 转成 06月15日 14:30"。每次需求一出来,我第一反应都是打开 MySQL 文档找DATE_FORMAT()。这个函数在 MySQL 里的地位,差不多相当于 Java 里的 SimpleDateFormat,是你绕不开的基础工具。今天这篇就专门聊聊DATE_FORMAT()的使用,从格式符到实战场景,再到那些文档里不会写的坑,尽量一次讲透。不管你是刚学 SQL 的新手,还是已经写了两年代码的熟手,这篇都值得花几分钟过一遍。如果你在项目里遇到过"为什么按天统计出来的结果不对""为什么用了函数之后查询变慢了"这类问题,那更要看到最后,答案都在里面。

1. 为什么非要用 DATE_FORMAT() 不可

1.1 DATETIME 与展示格式之间的鸿沟

先想一个问题:数据库里存的时间是2024-06-15 14:30:00这种样子,但业务方要的却是"6月15日 14:30""2024年第24周""2024 Q2 财报口径"。这些展示需求,你在 SQL 层处理还是拿到程序里处理?

我见过不少同事习惯在 Java 或 Python 里用 SimpleDateFormat 或 strftime 再转一次。这么做虽然能出结果,但有个很实际的问题:数据在传输过程中多了一次序列化和反序列化,而且每个查询都要把原始时间捞回来,程序里再做一轮循环处理。如果这个报表只需要跑一次还好,跑得勤了,网络开销和 CPU 开销都白白浪费。

更重要的是,MySQL 的 DATETIME 类型本身不存储任何格式信息,它只存一个"时间点",怎么展示完全取决于 SQL 层怎么处理。DATE_FORMAT()就是在数据库这一侧把"时间点"直接转成你想要的目标字符串,一次搞定,不折腾应用程序。这个函数从 MySQL 4.0 时代就有了,一直稳健到今天,凡是做数据统计、报表导出、日志分析的人,早晚都得跟它打交道。

1.2 DATE_FORMAT() 能解决什么问题

简单列一下我实际遇到过的场景,你就知道这个函数覆盖的范围有多广:

  • 日报、周报、月报的日期分组,比如"按月份统计订单量";
  • 日志表里把 UTC 时间转成东八区的可读格式;
  • 导出的 CSV、Excel 里需要"2024/06/15"这种斜杠格式;
  • 接口返回给前端时直接给格式化好的字符串,前端拿来即用;
  • 后台管理系统的筛选条件,比如"查询 2024 年第 24 周的数据"。

这些需求的核心都一样:把 DATETIME/TIMESTAMP 的存储值,变成人类更习惯、业务更需要的字符串格式。理解了这一点,你后面学格式符就不会迷路——你只是在告诉 MySQL"我想把时间点展示成什么样子"而已。

换个角度想,DATE_FORMAT()其实是一条"转换管道":左边是标准时间,右边是任意字符串。你控制的只是中间那组%格式符。所以与其死记硬背一堆函数用法,不如先把格式符这套"语法"吃透。

2. 核心语法与格式符逐项拆解

2.1 基础语法

DATE_FORMAT()的语法非常简单,就两个参数:

DATE_FORMAT(date, format)
  • date:必填,可以是 DATETIME、TIMESTAMP、DATE 类型的字段,也可以是NOW()、CURDATE()这样的表达式;
  • format:必填,一个字符串,里面写你要的格式符,格式符以%开头。

返回值是一个字符串。如果date是 NULL,结果也是 NULL;如果format里有无法识别的格式符,会原样输出那个字符,这一点后面细说。

最简单的例子:

SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'); -- 输出类似:2024-06-15 14:30:45

很多刚接触的人会疑惑,为什么输出跟原值看起来一样?因为%Y-%m-%d %H:%i:%s恰好就是 MySQL 默认的 DATETIME 展示格式。你可以把它理解成一套"拼图模板",每个%加一个字母就代表一个时间组件,拼出来是什么样,完全由你说了算。

2.2 格式符速查表

我整理了一份使用频率最高的格式符对照表,建议你直接收藏:

格式符含义示例(以 2024-06-15 14:30:45 为例)
%Y四位年份2024
%y两位年份24
%m两位月份(01-12)06
%c月份数字(1-12,无前导零)6
%d两位日(01-31)15
%e日(1-31,无前导零)15
%H24 小时制(00-23)14
%h12 小时制(01-12)02
%i分钟(00-59)30
%s秒(00-59)45
%pAM 或 PMPM
%W星期完整名称Saturday
%a星期缩写Sat
%M月份完整名称June
%b月份缩写Jun
%j一年中的第几天(001-366)167
%w星期几数字(0=周日)6
%T24 小时制完整时间14:30:45
%r12 小时制完整时间02:30:45 PM
%f微秒(6 位)000000

这里要特别提醒:%i是分钟,不是%M。很多人第一次写容易顺手写成%M,结果输出的是月份名称,查半天才发现是格式符搞错了。还有%h和%H只差一个大小写,含义却完全不同,一个是 12 小时制一个是 24 小时制,写错会导致时间直接少 12 小时。

2.3 高频组合

直接看组合更容易理解:

SELECT DATE_FORMAT(NOW(), '%Y-%m-%d'); -- 2024-06-15 SELECT DATE_FORMAT(NOW(), '%Y/%m/%d'); -- 2024/06/15 SELECT DATE_FORMAT(NOW(), '%Y年%m月%d日'); -- 2024年06月15日 SELECT DATE_FORMAT(NOW(), '%H:%i:%s'); -- 14:30:45 SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i'); -- 2024-06-15 14:30

看到没,除了格式符,你可以在模板里随意掺入中文、斜杠、横杠这些分隔符,它们会原样出现在结果里。这个特性在生成中文报表标题或导出文件名时特别有用,比如CONCAT('营收报表_', DATE_FORMAT(NOW(), '%Y%m%d'))就能拼出营收报表_20240615这样的文件名。

2.4 分隔符、中文与转义

有人会问:格式符模板里万一要输出一个真的%怎么办?比如想生成今年的完成率是 100%,直接写进去会发现%被当成格式符开头去匹配,结果不可控。MySQL 对这种情况没有提供像%%这种标准的转义方式,我的做法是用CONCAT拼接:把固定的%放在另一个字符串里绕过去。

SELECT CONCAT('完成率 ', 100, '%') AS rate_str;

另外,DATE_FORMAT的模板里可以写中文,结果也会正常返回,但要注意返回值的字符集。如果你的连接字符集是 utf8mb4,中文没有任何问题;如果库表是 latin1 或者连接时没指定 utf8mb4,中文可能出现乱码。排查这种问题时,先执行SHOW VARIABLES LIKE 'character_set_results';,一般就能看到端倪。

3. 真实场景中的典型用法

3.1 按天统计订单量

这是最经典的用法。假设有一张订单表,里面有create_time字段,你想看最近 7 天每天的订单量:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS order_cnt FROM orders WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day;

这里有两个点值得注意。第一,WHERE里没有对create_time做任何格式化处理,直接拿原始字段做范围过滤,这样能正常走索引。第二,GROUP BY后面的表达式和SELECT里的别名虽然看起来重复,但 MySQL 里GROUP BY可以引用SELECT里的别名,你也可以直接GROUP BY day,逻辑是一样的。

我习惯写成DATE_FORMAT(create_time, '%Y-%m-%d')全量表达式,因为这样在后端代码拼接 SQL 时不容易被某些框架的别名解析搞乱。统计出来的结果首页可能就是2024-06-10: 12 单、2024-06-15: 28 单这种很干净的格式,业务方直接拿去贴周报都能用。

3.2 按小时看接口调用趋势

数据量大了之后,日报不够看,还要关注一天内的小时分布。比如查某个接口在今天的各小时调用次数:

SELECT DATE_FORMAT(create_time, '%H:00') AS hour_slot, COUNT(*) AS call_cnt FROM api_log WHERE create_time >= CURDATE() GROUP BY DATE_FORMAT(create_time, '%H:00') ORDER BY hour_slot;

输出是00:00: 203 次、01:00: 158 次这种。你可以在%H:00后面再拼接别的维度,比如%Y-%m-%d %H:00就能同时看天和小时。实际做监控告警时,我经常用这个查询跑定时任务,把结果灌进一个汇总表,再配合告警规则判断是否有异常调用量。

有一点要注意:%H返回的是字符串,所以在ORDER BY hour_slot时字典序刚好和数值序一致,因为00到23补了零。一旦你改成%c这种不带前导零的格式,排序就会出问题,这个坑后面单独讲。

3.3 日志与导出文件可读化

再说一个我很常用的场景:日志表里的时间戳可能直接是 TIMESTAMP,导出给业务方看的时候,他们想看到2024-06-15 14:30:45,或者更口语化的06月15日 14:30。直接SELECT原始字段也不是不行,但导进 Excel 经常被自动识别成数字或莫名其妙的格式。

用 DATE_FORMAT 先转成你想要的字符串,导出后基本就是你看到的样子:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s') AS time_str, user_id, action FROM user_action_log WHERE create_time >= '2024-06-01 00:00:00';

如果业务方要的是"2024-06-15 14:30"这种不带秒的版本,就把模板里的:%s去掉。这种微调非常频繁,所以我一般会建议在导出接口里把格式模板作为参数暴露出去,让运营自己选,省得每次改需求都要重新提交一次代码。

3.4 按周、月、季度统计

除了按天,按月、按季度也是很常见的报表口径。DATE_FORMAT(create_time, '%Y-%m')可以按自然月分组,但季度就没有专门的%q格式符,需要自己算。

我的做法是用CONCAT拼接季度:先把月份用MONTH(create_time)取出来,然后计算(MONTH(create_time) + 2) DIV 3,最后拼成2024-Q2这种字符串。比如:

SELECT CONCAT(DATE_FORMAT(create_time, '%Y'), '-Q', (MONTH(create_time) + 2) DIV 3) AS quarter, COUNT(*) AS cnt FROM orders GROUP BY quarter;

按月分组则更简单:

SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(amount) AS total FROM orders GROUP BY month;

注意这里我在 SELECT 和 GROUP BY 里用了同一个表达式,MySQL 的 ONLY_FULL_GROUP_BY 模式允许这种写法,也建议你保持这种一致性,避免不同数据库方言之间出现兼容问题。

3.5 格式化字段参与排序的陷阱

这里要讲一个特别容易踩的坑。假设你想按月汇总数据,然后按月份排序:

SELECT DATE_FORMAT(create_time, '%m') AS month, SUM(amount) AS total FROM orders GROUP BY DATE_FORMAT(create_time, '%m') ORDER BY month;

看着没问题,但你用%m得到的是字符串'01'、'02'……'12',字符串排序按字典序走,虽然'01'到'09'顺序是对的,但一旦你用%c或者忘了补前导零,'10'就会排在'2'前面,结果乱成一团。

我踩过一次之后学乖了:排序永远用原始时间字段或数字,不要用格式化后的字符串。比如:

ORDER BY MIN(create_time);

或者干脆GROUP BY DATE_FORMAT(create_time, '%Y-%m') ... ORDER BY DATE_FORMAT(create_time, '%Y-%m'),因为%Y-%m按字典序刚好和自然顺序一致。这类经验总结成一句话:能用原始字段排序就不要用函数结果排序,这既是为了索引,也是为了排序的正确性。

4. 和 STR_TO_DATE、日期运算的联动

4.1 STR_TO_DATE 反向解析

DATE_FORMAT 是把时间转成字符串,STR_TO_DATE 正好反过来,把字符串按指定格式解析成 DATETIME。这两个函数经常成对出现,因为业务系统里从前端传入的时间往往是字符串,而且五花八门:

SELECT STR_TO_DATE('2024-06-15 14:30:45', '%Y-%m-%d %H:%i:%s'); -- 返回 DATETIME 类型:2024-06-15 14:30:45 SELECT STR_TO_DATE('2024/06/15', '%Y/%m/%d'); -- 返回 2024-06-15

格式符规则和 DATE_FORMAT 基本一致,但需要注意:解析失败时 STR_TO_DATE 返回 NULL 而不是报错。比如STR_TO_DATE('2024-13-15', '%Y-%m-%d')会返回 NULL,因为 13 月不存在。你在做数据清洗时,可以借助这个特性提前发现脏数据,比如:

SELECT * FROM temp_table WHERE STR_TO_DATE(raw_time, '%Y-%m-%d %H:%i:%s') IS NULL AND raw_time IS NOT NULL;

这样能把带不进来、格式异常的记录一次性捞出来,效率比在程序里逐条判断高很多。

4.2 配合日期运算做区间筛选

实际需求里,很少只改格式不改值。比如"查最近 30 天每月最后一天的数据",可以先算日期范围再格式化。最常用的日期函数是DATE_ADD、DATE_SUB、DATEDIFF、TIMESTAMPDIFF,它们和 DATE_FORMAT 配合起来很顺手:

SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '%Y-%m-%d') AS yesterday, DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-%d') AS next_month;

这里有个心得:能先用日期函数算完,再交给 DATE_FORMAT 格式化,就尽量别在格式化后的字符串上做运算。字符串日期做运算不仅慢,还容易出错。比如你非要把'2024-06-15'加上 1 天,得先 STR_TO_DATE 转回时间,再 DATE_ADD,绕一大圈,没有任何收益。

4.3 拼接 JSON 或导出内容

有时候你要给前端返回一个带时间的 JSON 字段,又不想多查一次数据库。可以直接在 SQL 里拼:

SELECT CONCAT( '{"date":"', DATE_FORMAT(create_time, '%Y-%m-%d'), '","time":"', DATE_FORMAT(create_time, '%H:%i'), '"}' ) AS json_time FROM orders WHERE id = 12345;

这种写法虽然简单,但我建议除非是临时排查数据,否则还是把原始时间抛给后端,让后端自己去格式化。因为 SQL 里拼 JSON 可读性差,后续维护很痛苦。这个取舍在团队协作时尤其重要,一个 SQL 里藏了三层函数嵌套,别人接盘的时候会想骂人。

4.4 与 CASE WHEN 结合做口径判定

还有个进阶玩法:用 DATE_FORMAT 配合 CASE WHEN 做业务口径判断。比如运营口中常说的"工作日"和"周末",其实可以用DAYOFWEEK()或者DATE_FORMAT(..., '%w')判断:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, CASE WHEN DATE_FORMAT(create_time, '%w') IN (1,2,3,4,5) THEN '工作日' ELSE '周末' END AS day_type, COUNT(*) AS cnt FROM orders GROUP BY day, day_type;

注意%w的取值是 0 到 6,0 代表周日。这种维度对分析订单分布、投放策略非常有用,SQL 写起来也不复杂。

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

5.1 NULL 与非法日期

DATE_FORMAT(NULL, '%Y-%m-%d')返回 NULL。这在统计的时候会带来隐形问题:比如按天分组时,create_time为 NULL 的记录会单独形成一组,如果不对 NULL 做处理,报表里就会出现一行NULL: 5 单,业务方看到会懵。

你可以在外层用IFNULL或COALESCE把结果兜住:

SELECT DATE_FORMAT(IFNULL(create_time, create_date), '%Y-%m-%d') AS day, COUNT(*) AS order_cnt FROM orders GROUP BY day;

另外,MySQL 对非法日期的容忍度取决于sql_mode。在非严格模式下,DATE_FORMAT('2024-02-30', '%Y-%m-%d')不会报错,而是返回一个可能让你一脸懵的值;在严格模式下会出现警告或报错。建议生产环境始终保持严格模式,至少在测试环境把这些脏数据暴露出来。

5.2 别在 WHERE 里用 DATE_FORMAT

这是最容易伤到性能的一条。经常有人问我:为什么我写了WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-06-15',执行计划里索引没生效?原因很简单,对字段做函数处理后,MySQL 无法直接走索引。正确写法是:

-- 差: WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-06-15' -- 好: WHERE create_time >= '2024-06-15 00:00:00' AND create_time < '2024-06-16 00:00:00'

范围查询不仅走索引,语义上也更清晰。如果确实要按某个格式化维度筛选,可以考虑在表里冗余一个日期字段,或者建立生成列索引。我自己更推荐后者,因为生成列更自动化,不用改业务代码。

5.3 月份、星期名称的语言问题

%M和%W返回的是英文月份名和星期名,比如 June、Saturday。如果业务要求显示中文"六月""星期六",单靠 DATE_FORMAT 做不到,因为 MySQL 的 locale 并不会因为连接字符集变化而切换。

我的做法是维护一张映射表,或者用CASE WHEN手工翻译:

SELECT CASE DATE_FORMAT(create_time, '%M') WHEN 'January' THEN '一月' WHEN 'February' THEN '二月' -- ... END FROM orders;

如果只是少数几个值,这样写还好;需要通用方案的话,直接用程序端模板引擎渲染反而更灵活。还有一种取巧方案:结果里同时带上%m和中文映射,让前端自己去替换,但那样耦合到了前端,我不太推荐。

5.4 大数据量下的分组统计优化

用DATE_FORMAT对全表做 GROUP BY,在小表上没问题,但到了千万级数据就会比较吃力。因为每个数据行都要做一次格式化计算,还不能走索引。

做报表时我一般分两步走:先把数据限定在必要的时间范围内,再用格式化分组。比如先WHERE create_time >= '2024-06-01',把数据腰斩到一个月,再执行分组,速度能快很多。如果依然慢,可以考虑把格式化后的日期存到独立的冗余列里,建上索引,按天查询直接命中。这本质是空间换时间,对喜欢把数据库当计算引擎用的团队,是个非常有效的优化手段。

5.5 时区问题

DATE_FORMAT 的输出依赖 MySQL 会话的时区设置。如果你的应用服务器和数据库服务器时区不一致,后端拿到的格式化结果可能和你预期差好几个小时。

排查这个问题时,先查:

SELECT @@global.time_zone, @@session.time_zone;

然后在连接池里统一设置时区,比如 JDBC URL 里指定serverTimezone=Asia/Shanghai,或者在连接初始化时执行SET time_zone = '+08:00'。格式化只是最后一步,源头时区错了,怎么格式化都是错的。

5.6 版本差异与 SQL 模式

MySQL 5.7 和 8.0 在 DATE_FORMAT 的基本行为上没有太大变化,主要差异会出现在严格模式和 ONLY_FULL_GROUP_BY 默认开启的组合上。5.7 之前,GROUP BY month这种引用 SELECT 别名的写法偶尔会因为模式不同而报错,8.0 里默认开启 ONLY_FULL_GROUP_BY,要求 SELECT 里的非聚合列必须出现在 GROUP BY 或属于聚合函数。

我踩过一个跟这相关的坑:按天分组时,SELECT 里同时写了DATE_FORMAT(create_time, '%Y-%m-%d')和create_time原始字段,在 ONLY_FULL_GROUP_BY 下直接报错。解决方案很简单,SELECT 里只保留分组表达式,原始字段没必要展示就不带。

6. 一个完整案例:订单报表的日/周/月统计

6.1 需求背景

有段时间我在做某公司的经营报表项目,运营每天要一张看板,核心指标是订单量、销售额。看板支持三个时间粒度切换:日、周、月。数据源就是一张订单主表,里面有create_time和amount。

最初版本是前端拿到全部原始时间,用 JavaScript 做格式化,结果随着订单量增长,接口响应越来越慢,页面要拉回几十万行数据。后来我把格式化逻辑下沉到 SQL 层,只返回统计好的聚合结果,响应时间从秒级降到毫秒级。

6.2 实现 SQL 与解析

日报用的查询就是前面提到的按天分组,这里重点说周报。MySQL 没有直接的"周"格式化符,我用了两种口径来处理。第一种是自然周,用YEARWEEK(create_time, 1)取周数,再配合%Y拼出2024-24这种格式;第二种是按"周一作为周起始日"的报表口径:

SELECT DATE_FORMAT( DATE_SUB(create_time, INTERVAL WEEKDAY(create_time) DAY), '%Y-%m-%d' ) AS week_start_day, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE create_time >= '2024-01-01' GROUP BY week_start_day ORDER BY week_start_day;

核心思路是先算出本周的起始日期,再用 DATE_FORMAT 把它转成可读字符串。WEEKDAY(create_time)返回 0 到 6,0 代表周一,所以用DATE_SUB往回减就能找到周一,这样一周的归属口径就统一了。

月报最简单,直接用%Y-%m分组即可。三份 SQL 写完后,我用 UNION ALL 把它们拼成一张总表,再灌进临时表,前端查询时只要按日期字段取数就行。

6.3 结果与复盘

上线后效果很好,但复盘时发现几个可以优化的点。第一,周报查询里用到了INTERVAL WEEKDAY(create_time) DAY,这在WHERE条件外不影响索引,但如果把同样的表达式用在WHERE里,就会出问题。第二,按周口径计算后,week_start_day是字符串,排序没问题,但业务方偶尔会要求展示"周一到周日"具体日期区间,这时就要再拼一个DATE_ADD(week_start_day, INTERVAL 6 DAY)。

这个案例给我最大的启示是:日期处理不是简单套一个函数,而是要先想清楚业务口径,再决定用什么表达式。DATE_FORMAT 只是展示层,真正的计算逻辑还是靠日期函数来完成。

7. 一些实操心得

写到这里,我回顾了一下自己这些年用 DATE_FORMAT 的经验,有几个体会想分享给你。

第一点,能用原始字段做过滤和排序,就别在函数里做。数据库索引对函数不友好,这个原则在 DATE_FORMAT 上体现得尤其明显。我见过太多人第一条 SQL 写得漂漂亮亮,结果 WHERE 里一个DATE_FORMAT(create_time, '%Y-%m-%d')直接把索引杀死,排查半天才发现。

第二点,DATE_FORMAT 的返回值是字符串,后续所有逻辑都要带着"字符串"的思维去考虑。比如字符串排序、空串、NULL,都和数字类型的处理逻辑不同。特别是做分组统计时,别把格式化的字符串再拿去做数值运算。

第三点,把 DATE_FORMAT 和 STR_TO_DATE 当成一对工具来学。前者做展示,后者做解析。很多看似复杂的报表需求,其实就是在"时间到字符串、字符串到时间"之间来回转换,想清楚什么时候该用哪个,代码会清爽很多。

最后再讲一个小技巧:如果你用的是 MySQL 8.0,DATE_FORMAT依然是处理日期展示的主力函数,不用花时间去找替代品。真正要留意的,是函数内部的格式符写错了不会报错,只会在结果上表现出怪异字符。我建议在最开始把常用格式符打印一遍看效果:

SELECT DATE_FORMAT('2024-06-15 14:30:45', '%Y|%y|%m|%c|%d|%e|%H|%h|%i|%s|%p|%W|%M|%j|%w') AS fmt_probe;

实测一次,哪些格式符返回什么,心里就有底了。希望这篇能帮你在处理 MySQL 日期格式化时少走弯路。

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

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

立即咨询