group by 日期后缺天是因为只对实际存在的记录分组,缺失日期不会生成空行;补零需用递归 cte 构造连续日期序列再左连接业务表。

为什么 GROUP BY 日期后缺天?
SQL 的 GROUP BY 只对实际存在的记录分组,原始表里没有某天的数据,结果里就完全不会出现那天的行。比如查 7 天内每日订单数,但中间有两天没下单,结果只有 5 行——这不是“值为 0”,是“根本不存在”。补零本质是把缺失的日期行主动构造出来再左连接。
用递归 CTE 生成连续日期序列(PostgreSQL / SQL Server / SQLite3)
核心思路:先造一个完整日期范围的临时表,再和业务表 LEFT JOIN。递归 CTE 最通用,不依赖系统表或扩展:
WITH RECURSIVE date_range AS (
SELECT '2024-01-01'::DATE AS dt
UNION ALL
SELECT dt + INTERVAL '1 day'
FROM date_range
WHERE dt
-
DATE(o.created_at) 确保时间戳字段只比对日期部分,避免时区或精度干扰
- 递归终止条件必须用
而非 <code>,否则会多出一天
- PostgreSQL 中
INTERVAL '1 day' 可用,MySQL 8.0+ 需改用 DATE_ADD(dt, INTERVAL 1 DAY)
MySQL 5.7 或无递归支持时用数字表模拟
没有递归 CTE 时,可借力 information_schema.columns(只要它有足够行数)或自建数字表:
SELECT DATE_ADD('2024-01-01', INTERVAL seq.num DAY) AS dt,
COALESCE(cnt.cnt, 0) AS cnt
FROM (
SELECT 0 AS num UNION SELECT 1 UNION SELECT 2 UNION SELECT 3
UNION SELECT 4 UNION SELECT 5 UNION SELECT 6
) seq
LEFT JOIN (
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01' AND created_at
- 手写
UNION 数字表简单直接,但超过 100 天就得拼更多 UNION,维护成本高
- 子查询
cnt 必须加 WHERE 限定日期范围,否则 LEFT JOIN 后可能匹配到范围外数据,导致计数错乱
- 用
DATE_ADD(..., INTERVAL n DAY) 比拼字符串更可靠,避免格式解析失败
注意时区与日期函数的隐式转换陷阱
created_at 是 TIMESTAMP WITH TIME ZONE 还是 DATETIME,直接影响 DATE() 或 CAST 行为:
- PostgreSQL 中
DATE(created_at AT TIME ZONE 'UTC') 才能确保按 UTC 日切分,否则按数据库本地时区
- MySQL 5.7 对
DATETIME 字段用 DATE() 安全,但若字段是 TIMESTAMP 且数据库时区≠应用时区,结果可能偏移一天
- 所有日期字面量(如
'2024-01-01')建议显式转类型:DATE '2024-01-01'(PG)、STR_TO_DATE('2024-01-01', '%Y-%m-%d')(MySQL)
真正容易被忽略的是:补零逻辑本身不难,难在日期边界是否严格对齐业务定义的“日”。比如订单统计按用户时区还是服务器时区?凌晨 00:03 的单算哪天?这些必须在生成 date_range 和关联条件里同步对齐,否则补出来的“零”本身就是错的。
LEFT JOIN。递归 CTE 最通用,不依赖系统表或扩展:
WITH RECURSIVE date_range AS ( SELECT '2024-01-01'::DATE AS dt UNION ALL SELECT dt + INTERVAL '1 day' FROM date_range WHERE dt
-
DATE(o.created_at)确保时间戳字段只比对日期部分,避免时区或精度干扰 - 递归终止条件必须用
而非 <code>,否则会多出一天 - PostgreSQL 中
INTERVAL '1 day'可用,MySQL 8.0+ 需改用DATE_ADD(dt, INTERVAL 1 DAY)
MySQL 5.7 或无递归支持时用数字表模拟
没有递归 CTE 时,可借力 information_schema.columns(只要它有足够行数)或自建数字表:
SELECT DATE_ADD('2024-01-01', INTERVAL seq.num DAY) AS dt,
COALESCE(cnt.cnt, 0) AS cnt
FROM (
SELECT 0 AS num UNION SELECT 1 UNION SELECT 2 UNION SELECT 3
UNION SELECT 4 UNION SELECT 5 UNION SELECT 6
) seq
LEFT JOIN (
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01' AND created_at
- 手写
UNION 数字表简单直接,但超过 100 天就得拼更多 UNION,维护成本高
- 子查询
cnt 必须加 WHERE 限定日期范围,否则 LEFT JOIN 后可能匹配到范围外数据,导致计数错乱
- 用
DATE_ADD(..., INTERVAL n DAY) 比拼字符串更可靠,避免格式解析失败
注意时区与日期函数的隐式转换陷阱
created_at 是 TIMESTAMP WITH TIME ZONE 还是 DATETIME,直接影响 DATE() 或 CAST 行为:
- PostgreSQL 中
DATE(created_at AT TIME ZONE 'UTC') 才能确保按 UTC 日切分,否则按数据库本地时区
- MySQL 5.7 对
DATETIME 字段用 DATE() 安全,但若字段是 TIMESTAMP 且数据库时区≠应用时区,结果可能偏移一天
- 所有日期字面量(如
'2024-01-01')建议显式转类型:DATE '2024-01-01'(PG)、STR_TO_DATE('2024-01-01', '%Y-%m-%d')(MySQL)
真正容易被忽略的是:补零逻辑本身不难,难在日期边界是否严格对齐业务定义的“日”。比如订单统计按用户时区还是服务器时区?凌晨 00:03 的单算哪天?这些必须在生成 date_range 和关联条件里同步对齐,否则补出来的“零”本身就是错的。
UNION 数字表简单直接,但超过 100 天就得拼更多 UNION,维护成本高cnt 必须加 WHERE 限定日期范围,否则 LEFT JOIN 后可能匹配到范围外数据,导致计数错乱DATE_ADD(..., INTERVAL n DAY) 比拼字符串更可靠,避免格式解析失败created_at 是 TIMESTAMP WITH TIME ZONE 还是 DATETIME,直接影响 DATE() 或 CAST 行为:
- PostgreSQL 中
DATE(created_at AT TIME ZONE 'UTC')才能确保按 UTC 日切分,否则按数据库本地时区 - MySQL 5.7 对
DATETIME字段用DATE()安全,但若字段是TIMESTAMP且数据库时区≠应用时区,结果可能偏移一天 - 所有日期字面量(如
'2024-01-01')建议显式转类型:DATE '2024-01-01'(PG)、STR_TO_DATE('2024-01-01', '%Y-%m-%d')(MySQL)
date_range 和关联条件里同步对齐,否则补出来的“零”本身就是错的。










