group by 不补缺省日期,需先生成完整日期序列再 left join;postgresql 用 generate_series(),mysql 用递归 cte 或变量法;关键在时间范围、对齐维度与 where 位置。

GROUP BY 本身不会补日期——它只对已有数据分组,缺的行根本不会出现。要补全,必须在 GROUP BY 之前把缺失的日期“造出来”,再用 LEFT JOIN 拉进来。
为什么直接 GROUP BY 出不来空日期?
因为 SQL 是“有数据才处理”。比如某用户 2023-01-05 没下单,SELECT DATE(created_at), COUNT(*) FROM orders WHERE user_id = 123 GROUP BY DATE(created_at) 就永远不会返回这一行。这不是 bug,是行为本质。
常见错误现象:GROUP BY 结果里日期不连续、折线图断点、同比计算错位。
- 别指望加
HAVING COUNT(*) = 0或WHERE ... IS NULL来捞出空日期——它们在GROUP BY后执行,此时空日期根本不存在 - 也别在
SELECT里用CASE WHEN判断日期是否存在——没这行,就无从判断
PostgreSQL:用 generate_series() 最省事
核心是先生成目标范围内的完整日期序列,再左连接业务表。注意起点、终点、步长类型必须匹配。
- 写成
generate_series('2023-01-01', '2023-01-31', '1 day')—— 必须带单位,不能写'1' - 如果补月份,终点得是当月 1 日(如
'2023-12-01'),步长写'1 month' - 关联时业务字段要对齐:用
DATE(created_at)或DATE_TRUNC('day', created_at)::DATE,确保和生成列类型一致 - 聚合时必须用
COALESCE(SUM(t.amount), 0),否则空行对应字段为NULL,SUM虽忽略但结果仍是NULL
示例:
SELECT d.dt, COALESCE(SUM(o.amount), 0) AS sales
FROM generate_series('2023-01-01'::DATE, '2023-01-31'::DATE, '1 day') AS d(dt)
LEFT JOIN orders o ON DATE(o.created_at) = d.dt
GROUP BY d.dt
ORDER BY d.dt;
MySQL 8.0+:递归 CTE 可用,但得防爆栈
MySQL 不支持 generate_series(),得靠 WITH RECURSIVE 手动构造序列。关键在边界控制和递归深度。
- 初始值必须来自子查询,如
(SELECT MIN(date) FROM events),不能硬写死导致递归不触发 - 递归体里必须用
DATE_ADD(dt, INTERVAL 1 DAY),不能写dt + 1(类型错) - 默认递归上限是 1000 层,补一年要加
SET SESSION cte_max_recursion_depth = 366 - 若只查最近 30 天,建议用变量法(
@i := @i + 1)配合足够大的驱动表,比递归更稳
所有数据库都该避开的坑
补日期不是拼功能,是保语义。最容易被忽略的是分组维度对齐问题。
- 按
user_id分组时,不能只生成全局日期序列再LEFT JOIN——会导致每个用户都套同一套日期,丢失个人活跃周期。正确做法是先算每人MIN(dt)/MAX(dt),再对每组调LATERAL(PG)或嵌套 CTE(MySQL/SQL Server) -
WHERE条件若写在最终SELECT外层,会把补出来的空行过滤掉;应移到JOIN ... ON里,比如ON o.date = d.dt AND o.status = 'paid' - 用预建
dim_date表虽简单,但务必检查其覆盖范围——查 2026-07 的数据,而表只到 2026-06,补出来的就是全NULL,COALESCE填 0 会掩盖真实缺失
真正麻烦的从来不是“怎么生成日期”,而是“生成哪一段”和“跟谁对齐”。没想清楚分组粒度和时间窗口,代码写得再漂亮,结果也是错的。










