《Python编程入门》11.1 datetime、zoneinfo 与时区

datetime 与时区是 Python 最易踩坑的标准库之一。本节从时间戳讲起,理清 naive 与 aware 的区别,实测 utcnow() 的弃用警告,用 zoneinfo 演示 Asia/Shanghai 与 America/New_York 的夏令时边界,并对比 replace 与 astimezone 的经典陷阱、ISO 8601 与 timedelta。

本节目标:分清时间戳、naive/aware 日期与带时区日期,用 zoneinfo 正确处理夏令时,避开 replace 与 astimezone 的经典陷阱。
适用版本:Python 3.12+(实测 3.14.6)

11.1 datetime、zoneinfo 与时区

10.3 节我们把数据序列化成 JSON,可一旦数据里带上时间,麻烦就来了:"2026-09-11T10:00:00" 到底是北京时间还是 UTC?本节把「时间」这件事从头理清——这是后面写日志、做命令行工具、处理接口时间戳的共同基础。

11.1.1 两种「时间」:时间戳与结构化日期

Python 里表示时间有两套并行的模型。第一套是 time 模块的时间戳(Unix epoch 秒):

import time

print(time.time())        # 从 1970-01-01 UTC 起的浮点秒
print(time.time_ns())     # 整数纳秒,无浮点误差
1791502397.5738492
1791502397573856000

时间戳是一个绝对量:全世界任何一台机器读到它,指向的都是同一个瞬间。它的缺点是「不好读」,你没法一眼看出这是几月几号。

第二套是 datetime 模块的结构化对象,由年、月、日、时、分、秒等字段组成,打印出来是 2026-09-11 10:30:00 这样直观的形态,还能用 .year、.month、.hour 等属性取出各个字段。一个 datetime 对象背后仍对应一个时间戳,但只有在它知道自己属于哪个时区时,这个映射才是确定的。这就引出了本节最核心的一组概念。

11.1.2 naive 与 aware:差一个时区信息

datetime 对象分两类:

  • naive(朴素):没有时区信息,tzinfo 为 None。
  • aware(带时区):tzinfo 不为 None,能唯一确定一个瞬间。
from datetime import datetime, timezone

naive = datetime(2026, 9, 11, 10, 0, 0)
aware = datetime(2026, 9, 11, 10, 0, 0, tzinfo=timezone.utc)
print(naive, "| tzinfo:", naive.tzinfo)
print(aware, "| tzinfo:", aware.tzinfo)
print("naive.timestamp() =", naive.timestamp())
print("aware.timestamp() =", aware.timestamp())
2026-09-11 10:00:00 | tzinfo: None
2026-09-11 10:00:00+00:00 | tzinfo: UTC
naive.timestamp() = 1789092000.0
aware.timestamp() = 1789120800.0

两者相差整整 28800 秒(8 小时)——因为 naive.timestamp() 把 10:00 当成了本机时区(本机为 UTC+8)来解释。naive 对象的含义取决于运行环境,换一台时区不同的机器,同一个对象就指向不同的瞬间。这是无数线上事故的根源。

铁律:跨进程、跨机器、存数据库、进日志的时间,一律用 aware 对象。 naive 只适合「本地展示」这种不需要换算的场景。

11.1.3 now() 的三种写法与 utcnow() 的弃用

获取当前时间有三种常见写法,行为并不相同:

from datetime import datetime, timezone

print("now()     ->", datetime.now())
print("now(UTC)  ->", datetime.now(timezone.utc))
print("utcnow()  ->", datetime.utcnow(), "(naive!)")
now()     -> 2026-10-09 07:39:28.772965
now(UTC)  -> 2026-10-08 23:39:28.773094+00:00
utcnow()  -> 2026-10-08 23:39:28.778016 (naive!)
写法返回评价
datetime.now()naive,本机时区的墙上时间只适合本地展示
datetime.now(timezone.utc)aware,UTC推荐
datetime.utcnow()naive,值是 UTC 但没带 tzinfo已弃用,别再用

utcnow() 看似方便,但它返回的是 naive 对象——值虽然是 UTC,却没有任何标记说明这一点,很容易被误当成本地时间。从 3.12 起它已被标记为弃用,3.14.6 实测会打出警告:

DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for
removal in a future version. Use timezone-aware objects to represent datetimes
in UTC: datetime.datetime.now(datetime.UTC).

正确写法是 datetime.now(timezone.utc),或者 3.11 起更短的别名 datetime.now(datetime.UTC)(datetime.UTC 就是 timezone.utc)。

11.1.4 zoneinfo:标准库自带的时区库

要处理「北京时间」「纽约时间」,就需要具体的时区规则,而这些规则来自 IANA 时区数据库。Python 3.9 起标准库内置了 zoneinfo,无需再装第三方 pytz:

from datetime import datetime
from zoneinfo import ZoneInfo

sh = ZoneInfo("Asia/Shanghai")
ny = ZoneInfo("America/New_York")

print(datetime(2026, 9, 11, 10, 0, tzinfo=sh))
print(datetime(2026, 9, 11, 10, 0, tzinfo=ny))
2026-09-11 10:00:00+08:00
2026-09-11 10:00:00-04:00

时区名用 "洲/城市" 形式(如 Asia/Shanghai、Europe/London、America/New_York),不要用 "CST" 这种缩写——它至少能指代中国标准时间、美国中部时间、古巴标准时间三个互相矛盾的时区。

11.1.5 夏令时边界实测

夏令时(DST)是时区问题的重灾区。以美国纽约为例,2026 年 3 月 8 日凌晨 2 点直接跳到 3 点(少一小时),11 月 1 日凌晨 2 点又拨回 1 点(多一小时)。zoneinfo 会自动套用这套规则:

from datetime import datetime
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
for label, dt in [
    ("2026-03-08 01:30", datetime(2026, 3, 8, 1, 30, tzinfo=ny)),
    ("2026-03-08 03:30", datetime(2026, 3, 8, 3, 30, tzinfo=ny)),
    ("2026-11-01 00:30", datetime(2026, 11, 1, 0, 30, tzinfo=ny)),
    ("2026-11-01 02:30", datetime(2026, 11, 1, 2, 30, tzinfo=ny)),
]:
    print(label, "->", dt, "| utcoffset:", dt.utcoffset(), "| dst:", dt.dst())
2026-03-08 01:30 -> 2026-03-08 01:30:00-05:00 | utcoffset: -1 day, 19:00:00 | dst: 0:00:00
2026-03-08 03:30 -> 2026-03-08 03:30:00-04:00 | utcoffset: -1 day, 20:00:00 | dst: 1:00:00
2026-11-01 00:30 -> 2026-11-01 00:30:00-04:00 | utcoffset: -1 day, 20:00:00 | dst: 1:00:00
2026-11-01 02:30 -> 2026-11-01 02:30:00-05:00 | utcoffset: -1 day, 19:00:00 | dst: 0:00:00

可以看到偏移量在 -05:00(标准时间)和 -04:00(夏令时)之间自动切换。作为对照,中国不实行夏令时,Asia/Shanghai 全年固定 +08:00,dst() 恒为 0:00:00。

11.1.6 replace 与 astimezone:一字之差的经典 bug

要把一个时间「换成另一个时区」,新手常写 replace(tzinfo=...),这是错误的。两者的区别是本节的必考项:

from datetime import datetime
from zoneinfo import ZoneInfo

sh = ZoneInfo("Asia/Shanghai")
ny = ZoneInfo("America/New_York")

meeting = datetime(2026, 3, 8, 15, 0, tzinfo=sh)
print("原始 (上海):", meeting)
print("replace(ny):", meeting.replace(tzinfo=ny), " <-- 时刻没变,只贴了新标签")
print("astimezone(ny):", meeting.astimezone(ny))
print("时刻相等?", meeting == meeting.replace(tzinfo=ny), "|", meeting == meeting.astimezone(ny))
原始 (上海): 2026-03-08 15:00:00+08:00
replace(ny): 2026-03-08 15:00:00-04:00  <-- 时刻没变,只贴了新标签
astimezone(ny): 2026-03-08 03:00:00-04:00
时刻相等? False | True
  • replace(tzinfo=ny):只改标签,不改时刻。它把「上海下午 3 点」硬说成「纽约下午 3 点」,实际上是把时间平移了 12 小时。
  • astimezone(ny):保持时刻不变,换算成纽约当地应该显示的时刻(凌晨 3 点)。这才是你想要的。

一句话记忆:replace 是「改字段」,astimezone 是「换算」。 要换时区,永远用 astimezone。

11.1.7 aware 日期做加减也会骗你

即便用上了 aware 对象,+ timedelta 也未必是「绝对时间」的加减。看这段跨越夏令时的代码:

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
before = datetime(2026, 3, 7, 12, 0, tzinfo=ny)
after = before + timedelta(days=1)
print("before:", before)
print("after :", after)
print("wall-clock 相减:", after - before)
print("按时间戳算真实间隔:", (after.timestamp() - before.timestamp()) / 3600, "小时")
before: 2026-03-07 12:00:00-05:00
after : 2026-03-08 12:00:00-04:00
wall-clock 相减: 1 day, 0:00:00
按时间戳算真实间隔: 23.0 小时

before + timedelta(days=1) 保持的是墙上时间(都是中午 12 点),而不是同一瞬间加 24 小时。因此 after - before 说「1 天」,实际却只过了 23 小时。要计算精确的时长,先把两端都换到 UTC 再相减。

11.1.8 ISO 8601 与 fromisoformat

在系统之间传递时间,通用格式是 ISO 8601。isoformat() 负责输出,fromisoformat() 负责解析:

from datetime import datetime

now = datetime(2026, 9, 11, 10, 30, 15, 123456)
print(now.isoformat())
print(now.isoformat(timespec="seconds"))
2026-09-11T10:30:15.123456
2026-09-11T10:30:15

3.11 起 fromisoformat() 大幅增强,能解析的格式比过去多得多:

from datetime import datetime, date

samples = [
    "2026-09-11T10:30:15+08:00",
    "2026-09-11 10:30:15",     # 空格分隔
    "2026-09-11T10:30:15Z",    # Z 后缀表示 UTC
    "2026-09-11",              # 只有日期
    "20260911T103015",         # 紧凑基本格式
]
for s in samples:
    print(f"{s!r:32} -> {datetime.fromisoformat(s)!r}")

print(date.fromisoformat("20260911"))
'2026-09-11T10:30:15+08:00'      -> datetime.datetime(2026, 9, 11, 10, 30, 15, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800)))
'2026-09-11 10:30:15'            -> datetime.datetime(2026, 9, 11, 10, 30, 15)
'2026-09-11T10:30:15Z'           -> datetime.datetime(2026, 9, 11, 10, 30, 15, tzinfo=datetime.timezone.utc)
'2026-09-11'                     -> datetime.datetime(2026, 9, 11, 0, 0)
'20260911T103015'                -> datetime.datetime(2026, 9, 11, 10, 30, 15)
2026-09-11

带 +08:00 或 Z 的字符串会解析出 aware 对象,不带时区的则解析成 naive 对象——这一点务必留意。

11.1.9 timedelta、strftime 与常用格式码

timedelta 表示一段时长,可正可负,支持各种单位:

from datetime import datetime, timedelta, timezone

t1 = datetime(2026, 9, 11, 10, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 11, 22, 30, tzinfo=timezone.utc)
print("差值:", t2 - t1, "=", (t2 - t1).total_seconds(), "秒")
print("t1 + 90min:", t1 + timedelta(minutes=90))
print("t1 + 1.5天:", t1 + timedelta(days=1, hours=12))
差值: 12:30:00 = 45000.0 秒
t1 + 90min: 2026-09-11 11:30:00+00:00
t1 + 1.5天: 2026-09-12 22:00:00+00:00

strftime() 把时间格式化成字符串,最常用的格式码如下:

码含义示例
%Y %m %d年-月-日2026 09 11
%H %M %S时:分:秒(24 小时制)14 05 09
%I %p12 小时制与 AM/PM02 PM
%A %a星期全称 / 缩写Friday Fri
%j一年中的第几天254
%z %Z时区偏移 / 名称+0800 CST

例如 datetime(2026, 9, 11, 14, 5, 9).strftime("%Y-%m-%d %H:%M:%S") 得到 2026-09-11 14:05:09;对带 Asia/Shanghai 时区的对象,strftime("%a %b %d %z %Z") 得到 Fri Sep 11 +0800 CST。注意 %z 和 %Z 只有 aware 对象才有内容,naive 对象会输出空字符串。

11.1.10 闰秒与单调时钟

两个容易被忽略的细节:

闰秒。POSIX 时间戳约定每天固定 86400 秒,并不建模闰秒,所以 datetime(2016, 12, 31, 23, 59, 60) 会直接抛 ValueError: second must be in 0..59, not 60。

测量耗时要用单调时钟。time.time() 会被 NTP 校时或手动改表影响,可能倒退;测量代码耗时应该用 time.monotonic()(或 time.perf_counter()),它只会单调递增,且没有「当前是几点」的语义:

import time

start = time.monotonic()
sum(range(1_000_000))
print("耗时(ms):", round((time.monotonic() - start) * 1000, 3))
耗时(ms): 8.211

小结

  • 时间戳(time.time())是绝对量但不好读;datetime 好读,但只有 aware 对象才对应确定的瞬间。
  • datetime.now(timezone.utc) 是获取 UTC 当前时间的推荐写法;utcnow() 返回 naive 对象,自 3.12 起已弃用。
  • 时区用 zoneinfo.ZoneInfo("洲/城市"),它会自动处理夏令时;Asia/Shanghai 全年固定 +08:00。
  • replace(tzinfo=...) 只改标签不改时刻(经典 bug),换时区必须用 astimezone();aware 对象的 + timedelta 也是墙上时间运算。
  • 传输用 ISO 8601,fromisoformat() 自 3.11 起能解析更多格式;测耗时用 time.monotonic()。

本节把「时间」讲清了,下一节要处理的正是它的孪生问题:程序运行时产生的日志也带时间戳,而且需要分级、定向、可关闭。想先看更全面的时间处理清单,可延伸阅读 Python datetime 完全指南 。

阅读导航:上一节:JSON / CSV / TOML 与序列化安全 · 下一节:logging 与结构化日志 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时