date_trunc('month', created_at)是最直接的按月分组方式,将时间截断为当月首日零点(如'2024-03-15 14:22:03'→'2024-03-01 00:00:00'),需用小写'month'、单引号包裹,timestamptz字段应先at time zone转换时区,且group by与select中表达式须严格一致。

DATE_TRUNC('month', created_at) 是最直接的按月分组方式
PostgreSQL 的 DATE_TRUNC 对时间字段做“截断”处理,'month' 模式会把任意时间(比如 '2024-03-15 14:22:03')归到当月第一天的零点('2024-03-01 00:00:00'),天然适合作为分组键。它比用 EXTRACT(YEAR FROM ...) 和 EXTRACT(MONTH FROM ...) 拼接更安全——不会出现 2024-1 与 2024-10 排序错乱的问题。
常见错误是误用 'mon' 或 'm':PostgreSQL 只认完整字符串 'month',其他缩写会报错 ERROR: invalid time frame 'mon'。
- 必须用单引号包裹
'month',不能写成month(否则被解析为列名或变量) - 输入字段类型需为
TIMESTAMP、TIMESTAMPTZ或DATE;若字段是字符串(如TEXT),先用TO_TIMESTAMP()或::TIMESTAMP转换 - 结果仍是
TIMESTAMP类型,如需只显示年月(如'2024-03'),后续可用TO_CHAR(..., 'YYYY-MM')格式化,但不要在GROUP BY中用它——会破坏索引利用和时区语义
带时区的时间字段要小心 timestamptz + DATE_TRUNC 的行为
如果 created_at 是 TIMESTAMPTZ,DATE_TRUNC('month', created_at) 默认按数据库当前时区(SHOW timezone)截断。例如服务器设为 'Asia/Shanghai','2024-03-01 00:00:00+00'(UTC)会被截成 '2024-02-29 00:00:00+08'(即北京时间 2 月最后一天)。
- 想统一按 UTC 月份统计?显式转换:
DATE_TRUNC('month', created_at AT TIME ZONE 'UTC') - 想按用户所在时区(比如字段里存了
user_timezone)?用动态时区:DATE_TRUNC('month', created_at AT TIME ZONE user_timezone),但注意该表达式无法走索引 - 若表数据量大且常按月查询,建议在常用时区上建函数索引,例如:
CREATE INDEX idx_orders_month_utc ON orders (DATE_TRUNC('month', created_at AT TIME ZONE 'UTC'));
统计时别漏掉空值和边界时间
DATE_TRUNC 本身对 NULL 返回 NULL,而 GROUP BY 会把所有 NULL 归为一组。如果你的 created_at 允许为空,这一组就是“未记录时间”的脏数据,容易干扰统计总数。
- 明确排除空值:
WHERE created_at IS NOT NULL写在GROUP BY前 - 检查是否有未来时间或明显异常值(如
'0001-01-01'),它们也会被截断并参与分组,建议加时间范围过滤:AND created_at BETWEEN '2020-01-01' AND NOW() - 如果要做同比(如 2024-03 vs 2023-03),注意
DATE_TRUNC('month', ...)结果是 timestamp,直接减整数月会出错;应改用(DATE_TRUNC('month', created_at) + INTERVAL '1 month')::DATE这类方式推算同期
性能关键:索引能不能用上 DATE_TRUNC?
单纯在 created_at 上建 B-tree 索引,对 DATE_TRUNC('month', created_at) 查询无效。PostgreSQL 16 支持函数索引,但必须严格匹配表达式。
- 正确建索引:
CREATE INDEX idx_orders_month ON orders (DATE_TRUNC('month', created_at)); - 查询时必须写完全一致的表达式,包括大小写和空格:
GROUP BY DATE_TRUNC('month', created_at)—— 少个空格或写成date_trunc小写都可能让优化器弃用索引 - 如果经常按不同粒度(日/周/月)统计,可建多个函数索引,但注意每个都会增加写入开销和磁盘占用
- 用
EXPLAIN验证是否命中索引:看到Index Scan using idx_orders_month才算成功
真正麻烦的是跨时区场景——DATE_TRUNC('month', created_at AT TIME ZONE 'UTC') 这种带函数调用的表达式,目前无法直接建高效索引,只能靠物化视图或冗余字段缓解。










