
文章目录
-
- [一、%y 的世纪分界卡在 68 和 69](#一、%y 的世纪分界卡在 68 和 69)
- [二、%b 不是「月份缩写」,是「当前 locale 的月份缩写」](#二、%b 不是「月份缩写」,是「当前 locale 的月份缩写」)
- 三、周编号有三套体系,跨年时全对不上
- [四、naive datetime 会静默套上本机时区](#四、naive datetime 会静默套上本机时区)
- 五、两个小坑,顺手记下
- 结论
上周同事报了个怪事:同一个脚本,本地跑出来的日期是 26-Oct-2026,丢到容器里跑就变成 26-10-2026,再用下游的解析逻辑读,直接 ValueError。
代码一个字没改,格式字符串是 %d-%b-%Y。差别只在容器的 LC_TIME 是 zh_CN.UTF-8。
标准库的日期格式化看起来最没有歧义,实际上它有一堆「取决于环境」的默认行为。我把 datetime 里容易踩的地方全跑了一遍(Python 3.13.12),五个坑,按危险程度排。

一、%y 的世纪分界卡在 68 和 69
用 %y-%m-%d 解析 68-01-01,得到 2068 年;解析 69-01-01,得到 1969 年。
两位年份的映射规则是固定的:00 到 68 映射到 2000 到 2068,69 到 99 映射到 1969 到 1999。相邻的两位数,一个往前一百年,一个往后一百年,跨度 99 年。
这个规则来自 POSIX 的约定,不是 Python 自己定的。危险的地方在于它不报错:一份 1969 年的老档案写成 69-05-12,解析出来是对的;但如果有人手写成 68-05-12 想表示 1968 年,会静默变成 2068 年,差整整一百年,而且 date 对象一切正常,不做范围检查。
防御方式很直接:解析两位年份时,拿到结果后加一句年份区间断言。历史数据断言在 1900 到 2030,未来数据断言在 2000 到 2100。断言不会让代码变慢,但会让错一百年这种事在开发阶段就炸出来。
二、%b 不是「月份缩写」,是「当前 locale 的月份缩写」
这是我这次踩得最狠的一条。同一个 datetime(2026, 10, 26),在四个 LC_TIME 环境下 strftime 的输出:
| LC_TIME | %d-%b-%Y |
%d-%B-%Y |
|---|---|---|
| en_US / C | 26-Oct-2026 | 26-October-2026 |
| zh_CN.UTF-8 | 26-10-2026 | 26-十月-2026 |
| zh_HK.UTF-8 | 26-10-2026 | 26-10月-2026 |
中文环境下 %b 直接退化成数字月份。更麻烦的是这一组实测结果:
- zh_CN 产出的
26-10-2026,拿到 en_US 环境用%d-%b-%Y读:ValueError; - zh_CN 产出的
26-十月-2026,拿到 en_US 用%d-%B-%Y读:ValueError; - 但同一份
26-10-2026,用%d-%m-%Y能成功读回 2026-10-26; - 反过来,en_US 产出的
26-Oct-2026用%d-%m-%Y读,ValueError。

第三、四条是关键。它意味着如果有人把格式符写错成 %m,在中文 locale 的产线上永远看不出问题,因为 %b 和 %m 产出完全一样;一旦某个环节的环境是英文 locale,才会突然炸。这类 bug 的触发条件不是代码改动,是部署环境的 locale。
三个可操作的做法:
- 跨系统交换的日期,只用数字格式(
%Y-%m-%d或 ISO 8601),不用%b、%B、%a、%p; - 必须解析月名缩写时,先用
locale.setlocale把LC_TIME锁成C再解析,因为strptime认的是 C locale 的英文月名; - 输出侧如果一定要
%b,在代码里显式设定 locale,别依赖容器镜像的默认值。
三、周编号有三套体系,跨年时全对不上
strftime 里跟周有关的格式符有两组,它们不是一个东西:
%W:周一为一周之首,第 0 周是元旦到第一个周一之前;%U:周日为一周之首;%G-%V-%u:ISO 8601 周,周一为首,跨年归属按 ISO 规则。
同一批日期在三种体系下的取值:
| 日期 | ISO | %W | %U |
|---|---|---|---|
| 2026-01-01 | 2026-W01 | 00 | 00 |
| 2027-01-01 | 2026-W53 | 00 | 00 |
| 2020-12-28 | 2020-W53 | 52 | 52 |
| 2021-01-03 | 2020-W53 | 00 | 00 |
2027 年 1 月 1 日,ISO 认为它属于 2026 年第 53 周,而 %W 认为它是第 0 周。两个答案都对,只是定义不同。如果你的周报系统和财务报表按 ISO 周对齐,用 %W 写就会在每年头尾错一到两周。
更阴的是解析方向。用 %Y-%W-%w 解析 2026-00-1,得到 2025-12-29:第 0 周被解释成上一年 12 月底。用同一格式解析 2026-53-1,得到 2027-01-04:2026 年其实没有第 53 周,但这个不存在的周号照样解析成功,直接溢出到下一年。
四个输入全部不报错,其中两个落在错误的年份。
要 ISO 周就用 ISO 的格式符:用 %G-W%V-%u 解析 2026-W53-5,正确得到 2027-01-01。注意是 %G 和 %V,不是 %Y 和 %W,写混了就静默错年。
四、naive datetime 会静默套上本机时区
一个不带 tzinfo 的 datetime 调用 .astimezone(),会直接补上系统本地时区:
python
dt.datetime(2026, 10, 26, 14, 30).astimezone()
# 2026-10-26 14:30:00+08:00
不报错,不警告。.timestamp() 同理,按本地时区换算成纪元秒。这台机器的 TZ 环境变量是空的,所以走了系统默认值 +08:00。换一台 TZ=UTC 的机器,同一个 naive 对象会变成 +00:00,时间戳差 8 小时,而代码看起来完全一样。
唯一会报错的是混合运算:naive 减 aware 直接 TypeError,这一条反而是保护。

顺着时区往下挖,还有个本地化的坑:香港的时区不是一直是 +8。我用 zoneinfo 逐年扫了一遍:
- 1941 年夏季,夏令时 +9;
- 1942 到 1945 年,全年 +9(日占时期改用东京时间,不是夏令时);
- 1946 到 1979 年,共 31 个年份实行夏令时,每年夏季 +9、冬季 +8;
- 1980 年起,固定 +8。
处理 1979 年以前的历史数据,如果按「香港就是 +8」直接减,遇到夏令时段会错一小时。这一小时在按日聚合时不明显,在小时级对齐、跨系统对账时就是明确的错位。
五、两个小坑,顺手记下
datetime.utcnow() 在 3.12 起标记弃用,实测会抛 DeprecationWarning,建议换 datetime.now(timezone.utc)。差别不只是 API 名字:utcnow() 返回的是 naive 对象(没有 tzinfo),所以它跟上一节那个隐式时区问题是同一个家族的。
%Y 对四位以下年份会补零:公元 1 年输出 0001,公元 999 年输出 0999。这在正常业务里碰不到,但解析历史文献、天文数据、或者做「年份合法性校验」时,len(str) == 4 这种检查会把 0001 和 0999 一起放过。
结论
五个坑的共性,用一句话说:strftime 和 strptime 的很多行为不由代码决定,由运行环境决定。 locale 决定月名长什么样,系统时区决定 naive 对象代表几点,两位年份的世纪窗口写在 POSIX 规范里而不是你的代码里。
所以防御也只能落在环境上:
- 跨系统只走 ISO 8601 数字格式;
- 解析月名前先锁
LC_TIME=C; - 周编号统一用
%G-%V-%u,别混%W; - 涉及时刻的对象一律带
tzinfo,naive 只用于「纯日期」场景; - 历史时区用
zoneinfo查真实偏移,别硬编码 +8。
五条都不难,难的是它们全都不报错。等到报错那天,错的数据已经进库了。
