最稳直接用date_format(created_at, '%y-%m')转成“2024-03”字符串再group by,比year()和month()拼接更安全,避免跨年时字符串排序错乱。

MySQL里用DATE_FORMAT截断到年月最稳
直接用DATE_FORMAT(created_at, '%Y-%m')就能把日期转成“2024-03”这种字符串,再GROUP BY它,统计就自动按月分开了。比手写YEAR()和MONTH()拼接安全得多——后者遇到跨年时排序会错乱(比如“2023-12”排在“2024-1”后面,但字符串比较是“2023”CONCAT(YEAR(d), MONTH(d))变成“202312”和“20241”,排序又崩了)。
- 别用
CONCAT(YEAR(d), '-', MONTH(d)):MONTH()返回1~12,没补零,“2024-1”和“2024-10”字符串排序错位 -
DATE_FORMAT默认按字典序排,“2024-01” - 如果字段是
DATETIME或TIMESTAMP,DATE_FORMAT照常工作,不用先DATE()转换
PostgreSQL必须用TO_CHAR或DATE_TRUNC
PostgreSQL不认DATE_FORMAT,硬套会报function date_format(unknown, unknown) does not exist。两种主流写法:TO_CHAR(created_at, 'YYYY-MM')生成字符串,或DATE_TRUNC('month', created_at)返回带时分秒的TIMESTAMP(如2024-03-01 00:00:00),后者更适合做范围查询(比如查某月全部记录时,可用BETWEEN DATE_TRUNC('month', now()) AND (DATE_TRUNC('month', now()) + INTERVAL '1 month' - INTERVAL '1 second'))。
- 用
TO_CHAR分组后结果是文本,不能直接加减月份;用DATE_TRUNC分组后仍是时间类型,可参与计算 -
DATE_TRUNC('month', created_at)性能略好——避免字符串转换开销,尤其大数据量时 - 注意
DATE_TRUNC返回的是当月第一天0点,不是“截断到月”这个语义的模糊理解
SQL Server得靠FORMAT或YEAR/MONTH组合
SQL Server 2012+支持FORMAT(created_at, 'yyyy-MM'),但性能差(隐式转换+CLR调用),大数据量慎用。更推荐YEAR(created_at) * 100 + MONTH(created_at)生成整数如202403,再GROUP BY——数值运算快,且天然保序。缺点是没法直接当日期用,要还原得写DATEFROMPARTS(YEAR(created_at), MONTH(created_at), 1)。
-
FORMAT在高并发场景可能引发CLR strict security报错,需DBA开启权限 - 用整数方式分组时,别漏掉
MONTH()补零逻辑——YEAR*100+MONTH本身已保证“202401”“202402”正确排序 - 如果表有索引在
created_at上,YEAR/MONTH组合无法走索引;DATEFROMPARTS构造范围条件才能利用索引
分组后想补全空月份?别硬写LEFT JOIN日历表
真实业务常需要“即使某月没数据也要显示0”,但临时建日历表太重。更轻量的做法是用递归CTE生成月份序列(PostgreSQL/SQL Server支持),或MySQL 8.0+用WITH RECURSIVE。关键是:生成的月份序列必须和主查询的截断方式完全一致,否则LEFT JOIN匹配不上。
- 比如主查询用
DATE_FORMAT(d, '%Y-%m'),日历表也得用同样表达式生成'2024-01',不能用CONCAT或YEAR/MONTH拼 - 递归生成月份上限别写死,建议用
(SELECT MAX(DATE_FORMAT(created_at, '%Y-%m')) FROM t)动态取 - SQLite不支持递归CTE,只能靠应用层补空,或者提前建好10年月份表(
CREATE TABLE months(yymm TEXT PRIMARY KEY))
按月分组看似简单,真正卡住人的永远是那几个边界:跨年排序、空月份补全、索引失效、不同数据库函数行为差异。尤其是把日期转字符串再分组时,一眼看不出的排序陷阱,往往要等报表上线才发现12月数据跑到了1月前面。










