Python 的 datetime 有个很阴的设计:同一个类型既可以带时区(aware),也可以不带(naive),两种对象长得一样、方法一样,大多数时候混着用也不报错。等它出问题的时候,通常是某个定时任务早跑了八个小时,或者报表里凭空多出一天。
下面七个坑都是实跑出来的,环境 Python 3.14.6,机器本地时区是 Asia/Shanghai(UTC+8)。
坑 1:naive 和 aware 比大小报错,比相等却静默返回 False
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
sh = ZoneInfo("Asia/Shanghai")
a = datetime(2026, 9, 30, 10, 0) # naive
b = datetime(2026, 9, 30, 10, 0, tzinfo=sh) # aware
a < b
# TypeError: can't compare offset-naive and offset-aware datetimes
a == b
# False
比大小会直接抛 TypeError,这个算友好。真正危险的是 ==:它不报错,永远返回 False。代码里写 if record.updated_at == last_sync: 这种判断,只要一边带时区一边不带,就永远走不进去,而且没有任何提示。
建议:在系统边界(读数据库、解析请求、读配置)统一转成 aware,内部只流通 aware 对象。
坑 2:utcnow() 返回的是 naive,timestamp() 会把它当本地时间
import time
u = datetime.utcnow()
round(u.timestamp() - time.time())
# -28800
round(datetime.now(timezone.utc).timestamp() - time.time())
# 0
utcnow() 返回的数值是 UTC 时间,但对象本身是 naive 的。timestamp() 遇到 naive 对象一律按本地时区解释,于是在东八区机器上差了 28800 秒,正好 8 小时。
这个 API 从 3.12 开始已经标记废弃,调用会触发 DeprecationWarning。替换成 datetime.now(timezone.utc) 即可。utcfromtimestamp() 同理,换成 fromtimestamp(ts, tz=timezone.utc)。
坑 3:replace(tzinfo=) 和 astimezone() 不是一回事
d = datetime(2026, 9, 30, 10, 0, tzinfo=timezone.utc)
d.replace(tzinfo=sh).isoformat()
# 2026-09-30T10:00:00+08:00
d.astimezone(sh).isoformat()
# 2026-09-30T18:00:00+08:00
replace 只是换标签,墙上时间不变,表示的已经是另一个时刻(早了 8 小时);astimezone 才是把同一个时刻换算到另一个时区。
什么时候用 replace?只有一种情况:你手上是一个 naive 时间,并且确定它本来就是某个时区的墙上时间,只是缺了标签。其余情况都应该用 astimezone。
坑 4:pytz 直接塞进构造函数,得到 +08:06
import pytz
datetime(2026, 9, 30, 10, tzinfo=pytz.timezone("Asia/Shanghai")).isoformat()
# 2026-09-30T10:00:00+08:06
+08:06 不是笔误。pytz 的时区对象默认取的是该地区历史上第一个偏移,也就是 1901 年前的地方平时(LMT)。pytz 要求必须用 tz.localize(dt),直接传给 tzinfo= 就会中招。
Python 3.9 起标准库有了 zoneinfo,可以直接放进构造函数,行为是对的。新项目没必要再引入 pytz,老项目里看到 tzinfo=pytz.timezone(...) 基本都是 bug。
坑 5:解析字符串时,时区信息说丢就丢
datetime.fromisoformat("2026-09-30T02:00:00Z").isoformat()
# 2026-09-30T02:00:00+00:00
datetime.strptime("2026-09-30 10:00:00", "%Y-%m-%d %H:%M:%S").tzinfo
# None
好消息是 3.11 起 fromisoformat 认识结尾的 Z 了(老版本会报错)。坏消息是,只要字符串里本来就没有偏移,解析出来就是 naive,后面所有运算都会按坑 1、坑 2 的方式出错。
接口约定时间格式时,尽量要求带偏移的 ISO 8601(+08:00 或 Z),或者干脆传 Unix 时间戳。如果对方只给了 2026-09-30 10:00:00,要在文档里问清楚这是哪个时区的墙上时间,然后在解析那一行就 replace(tzinfo=...) 补上标签。
坑 6:有夏令时的时区,加 24 小时不等于加一天
ny = ZoneInfo("America/New_York")
x = datetime(2026, 11, 1, 0, 30, tzinfo=ny)
y = x + timedelta(hours=24)
x.isoformat() # 2026-11-01T00:30:00-04:00
y.isoformat() # 2026-11-02T00:30:00-05:00
y.astimezone(timezone.utc) - x.astimezone(timezone.utc)
# 1 day, 1:00:00
2026 年 11 月 1 日美国结束夏令时。对 aware 对象做加法,Python 是在墙上时间上加的:墙上时间刚好过了 24 小时,但实际流逝了 25 小时。
国内业务一般碰不到,因为中国没有夏令时。但只要你的系统服务海外用户,或者要按用户所在时区算「每天几点提醒」,就一定会遇到。算「经过了多久」时,先转成 UTC 再相减。
坑 7:fromtimestamp 默认给本地时间,换台机器结果就变
ts = 1790733600
datetime.fromtimestamp(ts)
# 2026-09-30 10:00:00 (本机东八区)
datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
# 2026-09-30T02:00:00+00:00
不传 tz 时,结果取决于运行这段代码的机器时区。开发机是东八区,Docker 容器默认往往是 UTC,同一段代码在本地和线上差 8 小时。容器里设置 TZ 环境变量能缓解,但更稳的做法是代码里永远显式传 tz。
一套简单的约定
1. 存储和传输只用 UTC(数据库字段、消息队列、接口)。
2. 程序内部只流通 aware 对象,naive 在入口处就处理掉。
3. 只在展示给用户的那一刻,用 astimezone 转成用户所在时区。
4. 禁用 utcnow()、utcfromtimestamp()、不带 tz 的 now() 和 fromtimestamp(),可以在 lint 里加规则。
我自己在小站 forxi.cn 上写定时类的功能时,就是按这四条来约束代码的,坑 7 那种「本地好好的、进了容器差 8 小时」的问题基本就不会再出现。
说一句局限:上面的约定适合「时刻」类数据(下单时间、日志时间)。像生日、节假日、「每天早上九点」这种和具体时刻无关的日期或墙上时间,硬转成 UTC 反而会出问题,应该存成 date 或者「时间 + 时区名」,另外处理。
文章摘自:https://www.cnblogs.com/zengzhifei/p/23167163
