同一行 strftime,两台机器跑出两种日期

文章目录

上周同事报了个怪事:同一个脚本,本地跑出来的日期是 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。

三个可操作的做法:

  1. 跨系统交换的日期,只用数字格式(%Y-%m-%d 或 ISO 8601),不用 %b、%B、%a、%p;
  2. 必须解析月名缩写时,先用 locale.setlocale 把 LC_TIME 锁成 C 再解析,因为 strptime 认的是 C locale 的英文月名;
  3. 输出侧如果一定要 %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。

五条都不难,难的是它们全都不报错。等到报错那天,错的数据已经进库了。

相关推荐
2601_967987373 小时前
用Python+OpenCV+YOLO写个游戏脚本:从自动瞄准到自动采集的保姆级教程
python·opencv·yolo·目标检测·游戏自动化
meiying88383 小时前
快递单号批量查询Python脚本:从手动到自动化的完整实现
开发语言·python·自动化
人工智能导论实践课3 小时前
【GUI桌面开发3】PyCharm配置外部工具
ide·python·pycharm
一千柯橘3 小时前
LangChain 的实时输出处理: stream_events 和 Streaming
python·langchain·agent
贾公子3 小时前
图片 AIGC 检测接入实战——URL 与 Base64 的取舍、体积校验与「你送过去的可能不是那张图」
python·aigc
SEO_juper4 小时前
Node.js 重定向链检测工具:301 跳了几跳、有没有成环,一次看清
xml·开发语言·python·node.js·json·bash
曲径海盗4 小时前
【Python 学习第 37 天】for 循环
python·学习笔记·编程入门
心静财富之门4 小时前
Python 数据共享四种方案完整实例(同进程 / JSON / Redis / HTTP)
数据库·python·oracle
王仔小奶5 小时前
慢SQL治理与性能调优:DBA如何用Shell/Python提升运维效率?
python·sql·dba