日期时间格式化
本节讲解 PostgreSQL 日期时间值的输入解析与输出格式化。你将掌握 to_char 模式串、to_date / to_timestamp 解析、EXTRACT 与 date_trunc 截断、ISO 8601 与 ISO 周体系,以及报表统计中的常见写法。
前置知识
阅读本节前,建议先了解:时区与 TimeZone 设置
基础概念
PostgreSQL 把「日期时间的内部值」和「日期时间的文本表示」严格分开。前者是存储层的二进制值,后者由格式化函数或会话参数决定。理解这个分离是避免格式化陷阱的前提。
两类函数:输出格式化与输入解析
| 函数 | 方向 | 作用 | 典型用途 |
|---|---|---|---|
to_char(timestamp, text) | 值 → 文本 | 按模式串输出 | 生成报表、导出文件 |
to_char(interval, text) | 值 → 文本 | 间隔格式化 | 展示"用时 1小时30分" |
to_date(text, text) | 文本 → 值 | 解析为 DATE | 导入外部数据 |
to_timestamp(text, text) | 文本 → 值 | 解析为 TIMESTAMPTZ | 导入带时区的时间 |
to_number(text, text) | 文本 → 值 | 数值解析(相关) | 清洗财务数据 |
注意 to_timestamp 有两个重载:to_timestamp(double precision) 把 Unix 时间戳转成 TIMESTAMPTZ,to_timestamp(text, text) 按模式串解析文本。参数类型不同,行为完全不同。
两类截断函数:EXTRACT 与 date_trunc
| 函数 | 返回类型 | 作用 | 示例结果 |
|---|---|---|---|
EXTRACT(field FROM ts) | numeric | 取出单个字段 | EXTRACT(YEAR FROM ...) → 2026 |
date_part(text, ts) | double precision | 同上(老写法) | date_part('year', ...) → 2026 |
date_trunc(field, ts) | 同输入类型 | 向下截断到指定精度 | date_trunc('month', ...) → 月初零点 |
EXTRACT 返回 numeric,date_part 返回 double precision。两者功能重叠,新代码推荐 EXTRACT,因为它能返回精确的 numeric(对 INTERVAL 的秒值尤其重要)。
关键术语:
- 模式串(Pattern):由模式字符和普通文本组成的模板,如
'YYYY-MM-DD'。 - ISO 8601:国际标准化组织的日期时间表示标准,如
2026-09-22T10:30:00+08:00。 - ISO 周(ISO Week):以周一为一周起点、包含当年第一个周四的那一周为第 1 周。
- 季度(Quarter):
date_trunc('quarter', ...)支持的截断单位。 - 世纪(Century):
EXTRACT(CENTURY ...)返回21表示 2001–2100 年。
语法与用法
-- 输出格式化
to_char(value, pattern)
-- 输入解析
to_date(text, pattern)
to_timestamp(text, pattern)
-- 字段提取
EXTRACT(field FROM source)
date_part(field, source)
-- 精度截断
date_trunc(field, source)
-- 生成序列(报表补齐日期用)
generate_series(start, stop, step)to_char 的模式串可以拼接普通文本,但普通文本中的字母有被误认为模式字符的风险,需要用双引号包裹。
SELECT to_char(TIMESTAMP '2026-09-22 10:30:45.123', 'YYYY年MM月DD日 HH24:MI:SS'); to_char
-------------------
2026年09月22日 10:30:45-- 双引号把普通文本固定下来,避免被当作模式字符
SELECT to_char(TIMESTAMP '2026-09-22 10:30:45', 'YYYY"年"MM"月"DD"日"'); to_char
---------------------
2026年09月22日详细说明
to_char 模式串完整对照表
日期时间部分的模式字符:
| 模式 | 含义 | 示例输出 |
|---|---|---|
YYYY | 4 位年份 | 2026 |
YYY | 后 3 位年份 | 026 |
YY | 后 2 位年份 | 26 |
Y | 最后 1 位年份 | 6 |
IYYY | ISO 8601 周编号年 | 2026 |
IYY | ISO 周编号年后 3 位 | 026 |
MM | 月份(01–12) | 09 |
MON | 月份缩写 | SEP |
MONTH | 月份全称 | SEPTEMBER |
DD | 月内日(01–31) | 22 |
DDD | 年内日(001–366) | 265 |
DAY | 星期全称 | TUESDAY |
DY | 星期缩写 | TUE |
D | 星期序号(1–7,周日为 1) | 3 |
ID | ISO 星期序号(1–7,周一为 1) | 2 |
W | 月内周数(1–5) | 4 |
WW | 年内周数(1–53) | 38 |
IW | ISO 8601 周数(01–53) | 39 |
Q | 季度(1–4) | 3 |
CC | 世纪(2 位) | 21 |
时间部分的模式字符:
| 模式 | 含义 | 示例输出 |
|---|---|---|
HH24 | 24 小时制(00–23) | 10 |
HH12 | 12 小时制(01–12) | 10 |
HH | 同 HH12 | 10 |
MI | 分钟(00–59) | 30 |
SS | 秒(00–59) | 45 |
SSSS / SSSSS | 当日已过秒数(0–86399) | 37845 |
MS | 毫秒(000–999) | 123 |
US | 微秒(000000–999999) | 123456 |
AM / PM | 上下午标记 | AM |
TZ | 时区缩写 | CST(有歧义,慎用) |
OF | UTC 偏移 | +08 |
TZH | 时区小时部分 | +08 |
TZM | 时区分钟部分 | 00 |
一个把上述模式串全部用上的实测对比:
SELECT
to_char(ts, 'YYYY-MM-DD') AS iso_date,
to_char(ts, 'YYYY"Q"Q') AS year_quarter,
to_char(ts, 'IYYY-"W"IW') AS iso_week,
to_char(ts, 'IW') AS week_no,
to_char(ts, 'DAY') AS day_name,
to_char(ts, 'ID') AS iso_dow,
to_char(ts, 'DDD') AS day_of_year,
to_char(ts, 'HH24:MI:SS.US') AS time_full,
to_char(ts, 'TZH:TZM') AS utc_offset
FROM (SELECT TIMESTAMPTZ '2026-09-22 10:30:45.123456+08' AS ts) t; iso_date | year_quarter | iso_week | week_no | day_name | iso_dow | day_of_year | time_full | utc_offset
------------+--------------+-----------+---------+----------+---------+-------------+--------------------+------------
2026-09-22 | 2026Q3 | 2026-W39 | 39 | TUESDAY | 2 | 265 | 10:30:45.123456 | +08注意 DAY 输出是定长补空格到 9 个字符,如果需要去掉尾部空格,用 FM 前缀(Fill Mode):
SELECT
to_char(TIMESTAMP '2026-09-22', 'DAY') AS padded,
to_char(TIMESTAMP '2026-09-22', 'FMDAY') AS trimmed,
'[' || to_char(TIMESTAMP '2026-09-22', 'DAY') || ']' AS with_pad,
'[' || to_char(TIMESTAMP '2026-09-22', 'FMDAY') || ']' AS without_pad; padded | trimmed | with_pad | without_pad
-----------+---------+---------------+-------------
TUESDAY | TUESDAY | [TUESDAY ] | [TUESDAY]FM 还可以抑制数字前导零,如 FMMM 输出 9 而非 09。整个模式串前加一次 FM 即可作用于全部模式。
SELECT
to_char(TIMESTAMP '2026-09-02 08:05:00', 'MM-DD HH24:MI') AS normal,
to_char(TIMESTAMP '2026-09-02 08:05:00', 'FMMM-DD HH24:MI') AS fm_mode; normal | fm_mode
-------------+------------
09-02 08:05 | 9-02 08:05to_date 与 to_timestamp 解析
解析函数把外部文本转成日期时间值。两者行为差异在于返回类型和时区处理。
SELECT
to_date('2026/09/22', 'YYYY/MM/DD') AS d,
to_timestamp('2026-09-22 10:30:45', 'YYYY-MM-DD HH24:MI:SS') AS ts; d | ts
------------+-------------------------------
2026-09-22 | 2026-09-22 10:30:45+08to_timestamp(text, text) 返回 TIMESTAMPTZ,解析时按会话 TimeZone 解释输入文本(无偏移时)。
SET TimeZone TO 'Asia/Shanghai';
SELECT to_timestamp('2026-09-22 10:30:45', 'YYYY-MM-DD HH24:MI:SS') AS in_shanghai;
SET TimeZone TO 'UTC';
SELECT to_timestamp('2026-09-22 10:30:45', 'YYYY-MM-DD HH24:MI:SS') AS in_utc; in_shanghai
----------------------------
2026-09-22 10:30:45+08
(1 row)
in_utc
------------------------
2026-09-22 10:30:45+00
(1 row)同一个文本被解释成了不同的绝对时刻——相差 8 小时。这是解析函数的头号陷阱:to_timestamp(text, text) 依赖会话时区,结果不稳定。
-- 错误!会话时区变化会导致解析出不同的绝对时刻
INSERT INTO events (occurred_at)
VALUES (to_timestamp('2026-09-22 10:30:45', 'YYYY-MM-DD HH24:MI:SS'));正确做法是把偏移写进文本,并在模式串里用 TZH:TZM 显式接收:
-- 正确:偏移随文本一起解析,结果与会话时区无关
SELECT to_timestamp('2026-09-22 10:30:45+08', 'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS explicit_offset; explicit_offset
------------------------
2026-09-22 10:30:45+08解析失败的行为也需要注意。to_date 对格式不匹配的输入往往抛出异常:
SELECT to_date('2026-13-45', 'YYYY-MM-DD');ERROR: date/time field value out of range: "2026-13-45"
LINE 1: SELECT to_date('2026-13-45', 'YYYY-MM-DD');但某些"宽松"输入会被静默接受:
-- 注意:多余字符被忽略,没有报错
SELECT
to_date('2026-09-22 垃圾字符', 'YYYY-MM-DD') AS ignored_tail,
to_date('2026-09-22', 'YYYY-MM-DD HH24:MI:SS') AS missing_time; ignored_tail | missing_time
--------------+--------------
2026-09-22 | 2026-09-22to_date 的返回类型是 DATE,即使模式串里写了时间部分也会被丢弃。需要保留时间时用 to_timestamp。
EXTRACT 与 date_part
EXTRACT 是取出单个字段的标准写法,配合 date_trunc 可以完成绝大多数分组统计。
SELECT
EXTRACT(YEAR FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS yr,
EXTRACT(MONTH FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS mon,
EXTRACT(DAY FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS dy,
EXTRACT(HOUR FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS hr,
EXTRACT(MINUTE FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS mi,
EXTRACT(SECOND FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS sec,
EXTRACT(EPOCH FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS epoch,
EXTRACT(DOW FROM TIMESTAMPTZ '2026-09-22 10:30:45+08') AS dow,
EXTRACT(ISODOW FROM TIMESTAMPTZ '2026-09-22 10:30:45+08') AS isodow,
EXTRACT(QUARTER FROM TIMESTAMPTZ '2026-09-22 10:30:45+08') AS qtr; yr | mon | dy | hr | mi | sec | epoch | dow | isodow | qtr
------+-----+----+----+----+------------------+-------------------+-----+--------+-----
2026 | 9 | 22 | 10 | 30 | 45.123456 | 1789007445.123456 | 2 | 2 | 3EXTRACT(SECOND ...) 返回带小数秒的 numeric,这是它优于 date_part 的地方——date_part 返回 double precision,大时间戳下可能有精度损失。
SELECT
EXTRACT(SECOND FROM TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS extract_second,
date_part('second', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS date_part_second,
pg_typeof(EXTRACT(SECOND FROM now())) AS extract_type,
pg_typeof(date_part('second', now())) AS date_part_type; extract_second | date_part_second | extract_type | date_part_type
----------------+------------------+--------------+----------------
45.123456 | 45.123456 | numeric | double precisionEXTRACT(EPOCH ...) 把时间戳转成 Unix 秒数(自 1970-01-01 UTC),是与外部系统对接时最常用的转换。
-- 与 PHP time() / Java System.currentTimeMillis() 对接
SELECT
EXTRACT(EPOCH FROM TIMESTAMPTZ '2026-09-22 10:30:45+08')::BIGINT AS unix_seconds,
(EXTRACT(EPOCH FROM TIMESTAMPTZ '2026-09-22 10:30:45+08') * 1000)::BIGINT AS unix_millis; unix_seconds | unix_millis
--------------+---------------
1789007445 | 1789007445123DOW 与 ISODOW 的区别是分组统计中的经典坑:
| 字段 | 范围 | 周日起点 | 说明 |
|---|---|---|---|
DOW | 0–6 | 周日 = 0 | PostgreSQL 扩展,非 SQL 标准 |
ISODOW | 1–7 | 周一 = 1 | ISO 8601 标准 |
DAY 在 to_char | 1–7 | 周日 = 1 | 又一套编号,最容易记混 |
-- 同一批日期,三套星期编号结果不同
SELECT
to_char(d, 'YYYY-MM-DD') AS dt,
to_char(d, 'DAY') AS name,
EXTRACT(DOW FROM d) AS dow,
EXTRACT(ISODOW FROM d) AS isodow,
to_char(d, 'D') AS to_char_d,
to_char(d, 'ID') AS to_char_id
FROM generate_series(DATE '2026-09-20', DATE '2026-09-26', INTERVAL '1 day') AS d; dt | name | dow | isodow | to_char_d | to_char_id
------------+-----------+-----+--------+-----------+------------
2026-09-20 | SUNDAY | 0 | 7 | 1 | 7
2026-09-21 | MONDAY | 1 | 1 | 2 | 1
2026-09-22 | TUESDAY | 2 | 2 | 3 | 2
2026-09-23 | WEDNESDAY | 3 | 3 | 4 | 3
2026-09-24 | THURSDAY | 4 | 4 | 5 | 4
2026-09-25 | FRIDAY | 5 | 5 | 6 | 5
2026-09-26 | SATURDAY | 6 | 6 | 7 | 6做「周一到周日」报表时务必用 ISODOW 或 ID,否则周日会跑到行首。
date_trunc 截断
date_trunc 把时间向下取整到指定精度,是分组统计的主力函数。
SELECT
date_trunc('year', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS yr,
date_trunc('quarter', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS qtr,
date_trunc('month', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS mon,
date_trunc('week', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS wk,
date_trunc('day', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS dy,
date_trunc('hour', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS hr,
date_trunc('minute', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS mi,
date_trunc('second', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS sec; yr | qtr | mon | wk | dy | hr | mi | sec
-----------------------+----------------------+----------------------+----------------------+----------------------+----------------------+----------------------+----------------------
2026-01-01 00:00:00+08| 2026-07-01 00:00:00+08| 2026-09-01 00:00:00+08| 2026-09-21 00:00:00+08| 2026-09-22 00:00:00+08| 2026-09-22 10:00:00+08| 2026-09-22 10:30:00+08| 2026-09-22 10:30:45+08注意 date_trunc('week', ...) 返回的是周一(ISO 语义),不是周日。返回值类型与输入类型一致:输入 TIMESTAMPTZ 得到 TIMESTAMPTZ,输入 DATE 得到 TIMESTAMPTZ(DATE 会被提升)。
SELECT
pg_typeof(date_trunc('day', TIMESTAMPTZ '2026-09-22 10:30:45+08')) AS from_tstz,
pg_typeof(date_trunc('day', DATE '2026-09-22')) AS from_date,
pg_typeof(date_trunc('day', TIMESTAMP '2026-09-22 10:30:45')) AS from_ts; from_tstz | from_date | from_ts
-----------+-----------+-----------
timestamp with time zone | timestamp with time zone | timestamp without time zonedate_trunc 支持 milliseconds 与 microseconds,这是 PostgreSQL 相对 MySQL 的扩展:
SELECT
date_trunc('milliseconds', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS ms,
date_trunc('microseconds', TIMESTAMPTZ '2026-09-22 10:30:45.123456+08') AS us; ms | us
---------------------------+---------------------------
2026-09-22 10:30:45.123+08| 2026-09-22 10:30:45.123456+08ISO 8601 与 ISO 周体系
ISO 8601 规定:一周从周一开始,第 1 周是包含当年第一个周四的那一周。这意味着 12 月 31 日可能属于下一年的第 1 周,1 月 1 日也可能属于上一年的第 53 周。
-- 跨年边界的 ISO 周归属
SELECT
d::DATE AS dt,
to_char(d, 'IYYY') AS iso_year,
to_char(d, 'IW') AS iso_week,
EXTRACT(ISOYEAR FROM d) AS extract_isoyear,
EXTRACT(WEEK FROM d) AS extract_week
FROM (VALUES
(DATE '2025-12-28'), -- 周日,属于 2025 年第 52 周
(DATE '2025-12-29'), -- 周一,属于 2026 年第 1 周
(DATE '2026-01-01'), -- 周四,属于 2026 年第 1 周
(DATE '2027-01-01'), -- 周五,属于 2026 年第 53 周
(DATE '2027-01-04') -- 周一,属于 2027 年第 1 周
) AS t(d); dt | iso_year | iso_week | extract_isoyear | extract_week
------------+----------+----------+-----------------+--------------
2025-12-28 | 2025 | 52 | 2025 | 52
2025-12-29 | 2026 | 01 | 2026 | 1
2026-01-01 | 2026 | 01 | 2026 | 1
2027-01-01 | 2026 | 53 | 2026 | 53
2027-01-04 | 2027 | 01 | 2027 | 1关键点:ISO 周必须与 ISO 年配套使用。如果只取 to_char(d, 'YYYY-"W"IW'),2025-12-29 会输出 2025-W01,这是错误的——2025 年第 1 周其实是 2024 年底。
-- 错误!年份用 YYYY 而周数用 IW,跨年时会得到不存在的周
SELECT to_char(DATE '2025-12-29', 'YYYY-"W"IW') AS wrong, -- 2025-W01,但 2025 年第 1 周不是这天
to_char(DATE '2025-12-29', 'IYYY-"W"IW') AS correct; -- 2026-W01 wrong | correct
------------+-----------
2025-W01 | 2026-W01周数的三种编号也要区分:
SELECT
d::DATE AS dt,
to_char(d, 'WW') AS ww, -- 年内周数,1 月 1 日起每 7 天一周
to_char(d, 'IW') AS iw, -- ISO 周数
to_char(d, 'W') AS w, -- 月内周数
to_char(d, 'YYYY-"W"WW') AS yyyy_ww,
to_char(d, 'IYYY-"W"IW') AS iyyy_iw
FROM generate_series(DATE '2026-01-01', DATE '2026-01-15', INTERVAL '1 day') AS d; dt | ww | iw | w | yyyy_ww | iyyy_iw
------------+----+----+---+---------+---------
2026-01-01 | 01 | 01 | 1 | 2026-W01| 2026-W01
2026-01-02 | 01 | 01 | 1 | 2026-W01| 2026-W01
2026-01-03 | 01 | 01 | 1 | 2026-W01| 2026-W01
2026-01-04 | 01 | 01 | 1 | 2026-W01| 2026-W01
2026-01-05 | 02 | 02 | 1 | 2026-W02| 2026-W02
...输出为 JSON 与字符串拼接
导出给前端时通常需要把日期格式化成字符串再嵌入 JSON。to_char 的返回值本身就是 text,to_json 系列会自动加引号。
SELECT
to_json(to_char(TIMESTAMPTZ '2026-09-22 10:30:45+08', 'YYYY-MM-DD"T"HH24:MI:SSOF')) AS json_str,
to_json(TIMESTAMPTZ '2026-09-22 10:30:45+08') AS json_native,
jsonb_build_object(
'created_at', to_char(TIMESTAMPTZ '2026-09-22 10:30:45+08', 'YYYY-MM-DD HH24:MI:SS'),
'epoch', EXTRACT(EPOCH FROM TIMESTAMPTZ '2026-09-22 10:30:45+08')::BIGINT
) AS payload; json_str | json_native | payload
---------------------------------------+----------------------------------+---------------------------------------------------
"2026-09-22T10:30:45+08" | "2026-09-22T10:30:45.000000+08:00"| {"epoch": 1789007445, "created_at": "2026-09-22 10:30:45"}to_json(timestamptz) 自动产出符合 ISO 8601 的字符串,通常比手工拼模式串更可靠,因为它不受会话 DateStyle 影响:
-- DateStyle 影响直接输出,但不影响 to_char 与 to_json 的固定模式
SET DateStyle TO 'German, DMY';
SELECT
TIMESTAMPTZ '2026-09-22 10:30:45+08' AS direct_output,
to_char(TIMESTAMPTZ '2026-09-22 10:30:45+08', 'YYYY-MM-DD HH24:MI:SS') AS via_to_char,
to_json(TIMESTAMPTZ '2026-09-22 10:30:45+08') AS via_to_json;
RESET DateStyle; direct_output | via_to_char | via_to_json
---------------+---------------------+---------------------------------------
22.09.2026 10:30:45+08 | 2026-09-22 10:30:45 | "2026-09-22T10:30:45.000000+08:00"DateStyle 是会话级参数,直接输出格式随它变化;to_char 与 to_json 输出稳定——这是对外接口必须走格式化函数的原因。
实战示例
下面用一个订单报表场景,从建表到按周、按月、按季度统计,完整走一遍。
第 1 步:建表并准备数据
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL UNIQUE,
amount NUMERIC(12, 2) NOT NULL,
-- 下单时刻,用 TIMESTAMPTZ 记录绝对时间
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 显式指定时区,保证数据含义与运行环境无关
INSERT INTO orders (order_no, amount, created_at)
VALUES
('SO-202609-001', 199.00, TIMESTAMPTZ '2026-09-21 09:15:00+08'),
('SO-202609-002', 899.00, TIMESTAMPTZ '2026-09-21 21:40:00+08'),
('SO-202609-003', 150.50, TIMESTAMPTZ '2026-09-22 10:30:45+08'),
('SO-202609-004', 2380.00, TIMESTAMPTZ '2026-09-22 23:05:00+08'),
('SO-202609-005', 45.00, TIMESTAMPTZ '2026-09-23 00:10:00+08'),
('SO-202610-001', 760.00, TIMESTAMPTZ '2026-10-01 11:00:00+08'),
('SO-202611-001', 520.00, TIMESTAMPTZ '2026-11-15 16:20:00+08'),
('SO-202612-001', 310.00, TIMESTAMPTZ '2026-12-31 23:30:00+08'),
('SO-202701-001', 980.00, TIMESTAMPTZ '2027-01-01 08:00:00+08');INSERT 0 9第 2 步:按月统计并格式化展示
SELECT
to_char(date_trunc('month', created_at), 'YYYY-MM') AS month,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount
FROM orders
GROUP BY date_trunc('month', created_at)
ORDER BY date_trunc('month', created_at); month | order_cnt | total_amount
---------+-----------+--------------
2026-09 | 5 | 3673.50
2026-10 | 1 | 760.00
2026-11 | 1 | 520.00
2026-12 | 1 | 310.00
2027-01 | 1 | 980.00
(5 rows)这里 GROUP BY date_trunc('month', created_at) 会按会话时区换算后再截断。如果业务要求固定按北京时间统计,必须显式转换:
SELECT
to_char(date_trunc('month', created_at AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM') AS month,
COUNT(*) AS order_cnt
FROM orders
GROUP BY date_trunc('month', created_at AT TIME ZONE 'Asia/Shanghai')
ORDER BY 1;第 3 步:按 ISO 周统计,验证跨周归属
SELECT
to_char(created_at, 'IYYY-"W"IW') AS iso_week,
to_char(created_at, 'FMDAY') AS day_name,
COUNT(*) AS order_cnt
FROM orders
GROUP BY 1, 2
ORDER BY 1, 2; iso_week | day_name | order_cnt
-----------+----------+-----------
2026-W39 | MONDAY | 2
2026-W39 | TUESDAY | 2
2026-W39 | WEDNESDAY| 1
2026-W40 | THURSDAY | 1
2026-W46 | SUNDAY | 1
2026-W53 | THURSDAY | 1
2027-W01 | FRIDAY | 1
(7 rows)注意 2026-12-31 被归到 2026-W53,而 2027-01-01 被归到 2027-W01——这正是 ISO 周体系的规则。
第 4 步:按季度与年度汇总
SELECT
to_char(created_at, 'YYYY"年"Q"季度"') AS quarter_label,
date_trunc('quarter', created_at) AS quarter_start,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount
FROM orders
GROUP BY 1, 2
ORDER BY 2; quarter_label | quarter_start | order_cnt | total_amount
-----------------+----------------------+-----------+--------------
2026年3季度 | 2026-07-01 00:00:00+08 | 5 | 3673.50
2026年4季度 | 2026-10-01 00:00:00+08 | 3 | 1590.00
2027年1季度 | 2027-01-01 00:00:00+08 | 1 | 980.00
(3 rows)第 5 步:生成连续日期序列,补齐无数据的日子
报表经常要求「没有订单的日期也显示为 0」。generate_series 生成日期骨架后左连接即可。
WITH span AS (
SELECT generate_series(
date_trunc('day', TIMESTAMPTZ '2026-09-21 00:00:00+08'),
date_trunc('day', TIMESTAMPTZ '2026-09-24 00:00:00+08'),
INTERVAL '1 day'
) AS day_start
)
SELECT
to_char(s.day_start AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS day,
to_char(s.day_start AT TIME ZONE 'Asia/Shanghai', 'FMDAY') AS day_name,
COALESCE(COUNT(o.id), 0) AS order_cnt,
COALESCE(SUM(o.amount), 0) AS total_amount
FROM span s
LEFT JOIN orders o
ON o.created_at >= s.day_start
AND o.created_at < s.day_start + INTERVAL '1 day'
GROUP BY s.day_start
ORDER BY s.day_start; day | day_name | order_cnt | total_amount
------------+----------+-----------+--------------
2026-09-21 | MONDAY | 2 | 1098.00
2026-09-22 | TUESDAY | 2 | 2530.50
2026-09-23 | WEDNESDAY| 1 | 45.00
2026-09-24 | THURSDAY | 0 | 0.00
(4 rows)注意日期边界用 >= 当天起点 AND < 次日起点 的半开区间,而不是 BETWEEN——半开区间能正确处理带小数秒的时间戳,也更容易命中索引。
-- 不推荐:BETWEEN 是闭区间,会漏掉当天 23:59:59.000001 之后的数据
-- SELECT * FROM orders
-- WHERE created_at BETWEEN '2026-09-22 00:00:00+08' AND '2026-09-22 23:59:59+08';
-- 推荐:半开区间,边界清晰且可利用索引
SELECT order_no, amount, created_at
FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-09-22 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-09-23 00:00:00+08'; order_no | amount | created_at
---------------+---------+------------------------
SO-202609-003 | 150.50 | 2026-09-22 10:30:45+08
SO-202609-004 | 2380.00 | 2026-09-22 23:05:00+08
(2 rows)第 6 步:导出 JSON 报表
SELECT
jsonb_pretty(
jsonb_agg(
jsonb_build_object(
'month', to_char(date_trunc('month', created_at), 'YYYY-MM'),
'order_cnt', agg.cnt,
'total_amount', agg.total
)
ORDER BY agg.month_start
)
) AS report
FROM (
SELECT
date_trunc('month', created_at) AS month_start,
COUNT(*) AS cnt,
SUM(amount) AS total
FROM orders
GROUP BY 1
) agg
JOIN orders ON date_trunc('month', orders.created_at) = agg.month_start
GROUP BY agg.month_start, agg.cnt, agg.total; report
-----------------------------------------------
[
{
"month": "2026-09",
"order_cnt": 5,
"total_amount": 3673.50
},
...
]第 7 步:清理环境
DROP TABLE orders;DROP TABLE注意事项
模式串里的普通字母必须用双引号包裹:
'YYYY年MM月'中的中文安全,但'YYYY-MM-DDTHH24'里的T会被当作模式字符。不确定时一律写"T"。to_char不受会话DateStyle影响,但直接输出受:对外接口一律走to_char或to_json,不要让裸的时间戳参与字符串拼接。to_timestamp(text, text)依赖会话TimeZone:这是不稳定行为。必须解析外部时间时,把偏移写进文本并用TZH:TZM显式接收。EXTRACT(SECOND)返回numeric小数,而date_part返回double precision。做等值比较时要留意类型差异:sql-- 这个比较恒为 false,因为 EXTRACT 小数部分不为 0 SELECT EXTRACT(SECOND FROM TIMESTAMPTZ '2026-09-22 10:30:45+08') = 45 AS is_45;textis_45 ------- f正确写法是先取整:
EXTRACT(SECOND FROM ts)::INT = 45或date_trunc('second', ts)。ISO 周必须用
IYYY配IW,不能混用YYYY与IW,否则跨年时会输出不存在的周。三套星期编号不要混用:
DOW(周日=0)、ISODOW(周一=1)、to_char(..., 'D')(周日=1)。做周报表统一用ISODOW。date_trunc的返回值类型跟随输入:DATE输入会提升为TIMESTAMPTZ,然后参与会话时区换算,可能产生意外偏移。sqlSET TimeZone TO 'America/New_York'; -- DATE 被当作本地零点,截断结果带 -04/-05 偏移 SELECT date_trunc('day', DATE '2026-09-22') AS truncated_date; RESET TimeZone;texttruncated_date ------------------------- 2026-09-22 00:00:00-04to_date的返回类型是DATE,模式串中的时间部分会被丢弃。需要时间精度时用to_timestamp或::TIMESTAMP。to_date会静默忽略尾部多余字符,不会报错。清洗导入数据时不要依赖它做格式校验,应配合正则或IS JSON之类的显式检查。generate_series的时间步长支持INTERVAL,但不支持「加一个月」的语义统一:INTERVAL '1 month'在月末会夹取,生成序列时可能重复或跳变。sqlSELECT d::DATE FROM generate_series(DATE '2026-01-31', DATE '2026-05-31', INTERVAL '1 month') AS d;textd ------------ 2026-01-31 2026-02-28 2026-03-28 2026-04-28
需要严格月末序列时,改用按月递增的整数序号再计算日期。
## 最佳实践
- **存储层一律用原生日期时间类型(`TIMESTAMPTZ` / `DATE` / `INTERVAL`),不要把时间存成字符串**:字符串存储无法做范围查询优化,也无法保证格式一致。格式化只在输出边界做。
- **格式化的唯一出口是 `to_char` 或 `to_json`**:把格式化集中在视图或应用层,不要让同一个字段在多个查询里用不同模式串。
- **对外接口统一用 ISO 8601**:建议模式串 `'YYYY-MM-DD"T"HH24:MI:SS.USOF'`,或直接 `to_json(timestamptz)`。ISO 8601 是唯一无歧义的跨系统表示。
- **周报表统一用 ISO 周体系**:
```sql
SELECT
to_char(created_at, 'IYYY-"W"IW') AS iso_week,
MIN(created_at) AS week_start_hint
FROM orders
GROUP BY 1
ORDER BY 1;月度统计用
date_trunc('month', ...)而不是字符串截取:substring(to_char(created_at, 'YYYY-MM-DD'), 1, 7)既慢又绕,而且无法利用时间索引。日期范围查询用半开区间:
sql-- 推荐写法 WHERE created_at >= :start_date AND created_at < :end_date_exclusive这样调用方只需传两个日期,无需处理"当天最后一秒"这个永远写不对的值。
报表补齐日期用
generate_series+LEFT JOIN,而不是在应用层循环补零。数据库里一次算完,减少网络往返。按业务时区统计时先
AT TIME ZONE再date_trunc:sqlSELECT to_char(date_trunc('day', created_at AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM-DD') AS biz_day, COUNT(*) FROM orders GROUP BY 1 ORDER BY 1;顺序反了会先按会话时区截断,跨零点订单会落到错误的日子。
导入外部日期文本时先做格式校验:用
to_date配合异常处理,或先用正则~ '^\d{4}-\d{2}-\d{2}$'筛选,避免静默接受脏数据。为
to_char的高频模式串建立表达式索引时要谨慎:to_char依赖会话lc_time,不是IMMUTABLE,无法直接建索引。需要按天/月分组时改用date_trunc(对TIMESTAMP是IMMUTABLE)建表达式索引。sql-- 错误!to_char 不是 IMMUTABLE,无法建索引 -- CREATE INDEX idx_orders_day ON orders (to_char(created_at, 'YYYY-MM-DD')); -- 正确:date_trunc 对 TIMESTAMP 是 IMMUTABLE CREATE INDEX idx_orders_day ON orders (date_trunc('day', created_at AT TIME ZONE 'UTC'));
下一节
继续学习:日期时间类型选型指南