Python 基础体系 · 第 40/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 日期时间:datetime、时区、DST、时间戳和序列化

日期时间问题通常不是“如何把字符串解析成对象”,而是要先回答:

  1. 这个值表示一个日期、一天中的本地时间,还是一个确定的瞬时点
  2. 它是否包含时区信息?
  3. 计算的是“墙上时钟向后一天”,还是“经过了 24 个小时”?
  4. 传输和存储时,是否保留了足够的信息,能够恢复原来的语义?

Python 3.14 的标准库把这些概念分布在 datetimezoneinfotimejson 等模块中。真正可靠的时间处理,关键不在于记住若干格式字符串,而在于区分日历语义、时钟语义和瞬时语义


一、先建立时间模型

1. datetimedatetimetimedelta

datetime 模块提供几类核心对象:

  • date:年月日,不包含一天中的时间;
  • time:时分秒,不包含具体日期;
  • datetime:日期和时间的组合;
  • timedelta:两个日期时间之间的持续时间;
  • tzinfo:时区规则的抽象接口;
  • timezone:固定 UTC 偏移的时区实现。

date 使用向前、向后扩展的公历模型;timedatetime 的分辨率最高为微秒,并且不处理闰秒。datetime 对象的范围是公元 1 年到 9999 年。(docs.python.org)

import datetime as dt

birthday = dt.date(1990, 5, 20)
office_open = dt.time(9, 30)
created_at = dt.datetime(2026, 9, 1, 10, 15, 30)
delay = dt.timedelta(minutes=15)

print(birthday)
print(office_open)
print(created_at + delay)

输出:

1990-05-20
09:30:00
2026-09-01 10:30:30

这里的 timedelta(minutes=15) 表示持续 15 分钟,而不是某个时区中的“下一个 15 分钟刻度”。

timedelta 内部归一化为 dayssecondsmicroseconds 三部分。例如:

delta = dt.timedelta(hours=-1)

print(delta)
print(delta.days, delta.seconds, delta.microseconds)

输出类似:

-1 day, 23:00:00
-1 82800 0

因此:

delta.seconds

并不是总秒数。需要总时长时,应使用:

print(delta.total_seconds())

输出:

-3600.0

这是因为 seconds 始终被规范化到 086399 之间;它只是归一化表示的一部分。(docs.python.org)


二、naive 和 aware:有没有时区信息还不够

1. naive datetime

没有时区语义的 datetime 称为 naive datetime

naive = dt.datetime(2026, 9, 1, 12, 0, 0)

print(naive.tzinfo)

输出:

None

tzinfo is None 只是最常见的判断方式。严格来说,一个 datetime 只有在以下两个条件同时成立时才是 aware:

  1. tzinfo 不为 None
  2. tzinfo.utcoffset(datetime) 返回非 None

否则就是 naive。(docs.python.org)

naive 对象本身没有说明:

  • 它是 UTC;
  • 它是系统本地时间;
  • 它是上海时间;
  • 它是数据库服务器所在时区的时间。

因此下面两个值在语义上都不完整:

dt.datetime(2026, 9, 1, 12, 0)
dt.datetime(2026, 9, 1, 12, 0)

它们看起来相同,但程序无法从对象自身判断它们对应哪个瞬时点。

2. aware datetime

带有明确时区规则或固定偏移的 datetime 称为 aware datetime

utc_time = dt.datetime(
    2026, 9, 1, 12, 0,
    tzinfo=dt.UTC,
)

print(utc_time)
print(utc_time.tzinfo)
print(utc_time.utcoffset())

输出:

2026-09-01 12:00:00+00:00
UTC
0:00:00

Python 3.11 引入的 datetime.UTCdatetime.timezone.utc 的别名。Python 3.14 中,推荐使用 aware datetime 表示 UTC,而不是使用带有“假定为 UTC”含义的 naive datetime。(docs.python.org)

now_utc = dt.datetime.now(dt.UTC)

不要把以下代码当作“把时间转换成 UTC”:

local = dt.datetime(2026, 9, 1, 12, 0)
wrong = local.replace(tzinfo=dt.UTC)

replace(tzinfo=...) 不会改变时分秒,只会修改标签。它表达的是:

原来这个墙上时间就应该被解释为 UTC。

如果原始值实际是中国标准时间,那么正确的转换应先附加上海时区,再转换到 UTC:

from zoneinfo import ZoneInfo

local = dt.datetime(
    2026, 9, 1, 12, 0,
    tzinfo=ZoneInfo("Asia/Shanghai"),
)

utc = local.astimezone(dt.UTC)

print(local)
print(utc)

输出:

2026-09-01 12:00:00+08:00
2026-09-01 04:00:00+00:00

二者表示同一个瞬时点。


三、固定偏移和 IANA 时区不是一回事

1. timezone:固定 UTC 偏移

datetime.timezone 适合表示固定偏移:

china = dt.timezone(dt.timedelta(hours=8), name="UTC+08:00")

value = dt.datetime(2026, 9, 1, 12, tzinfo=china)
print(value)
print(value.tzname())

输出:

2026-09-01 12:00:00+08:00
UTC+08:00

固定偏移只回答:

此时比 UTC 早或晚多少?

它不包含某个地区在不同年份如何切换夏令时、历史上是否修改过时区规则等信息。

2. ZoneInfo:IANA 时区数据库

zoneinfo.ZoneInfo 使用 IANA 时区标识,例如:

  • Asia/Shanghai
  • America/Los_Angeles
  • Europe/London
from zoneinfo import ZoneInfo

shanghai = ZoneInfo("Asia/Shanghai")
los_angeles = ZoneInfo("America/Los_Angeles")

meeting = dt.datetime(2026, 9, 1, 9, 0, tzinfo=shanghai)

print(meeting)
print(meeting.astimezone(los_angeles))

ZoneInfotzinfo 的具体实现,能够依据 IANA 时区规则处理偏移变化和 DST。它从系统时区数据库或 tzdata 包读取规则;某些系统,尤其是 Windows,可能没有可用的 IANA 数据库,跨平台项目通常应显式依赖 tzdata。(docs.python.org)

python -m pip install tzdata

代码中的时区名称不应使用含糊缩写:

# 不推荐:EST 可能只表示固定偏移,也可能被不同系统解释不同
"EST"

# 推荐:包含地区和规则
"America/New_York"

UTC+08:00Asia/Shanghai 在当前很多日期上可能得到相同偏移,但它们携带的信息不同:

  • UTC+08:00:固定偏移;
  • Asia/Shanghai:地区规则,可能包含历史变化。

如果业务只需要计算当前偏移,固定时区足够;如果需要展示当地时间、处理历史数据或安排未来事件,应保存 IANA 时区名称。


四、时区转换的核心:保持瞬时点不变

astimezone(target) 的语义是:

把同一个瞬时点表示成目标时区的本地时间。

例如:

instant = dt.datetime(
    2026, 9, 1, 4, 0,
    tzinfo=dt.UTC,
)

shanghai = instant.astimezone(ZoneInfo("Asia/Shanghai"))
new_york = instant.astimezone(ZoneInfo("America/New_York"))

print(instant)
print(shanghai)
print(new_york)

输出会类似:

2026-09-01 04:00:00+00:00
2026-09-01 12:00:00+08:00
2026-09-01 00:00:00-04:00

换算关系可以写成:

UTC 时间=本地墙上时间UTC 偏移\text{UTC 时间} = \text{本地墙上时间} - \text{UTC 偏移}

上海的偏移为 +08:00,所以:

12:008小时=04:00 UTC12{:}00 - 8\text{小时} = 04{:}00\ UTC

astimezone() 的实现逻辑可以近似理解为:

def convert(value, target):
    utc_like = (value - value.utcoffset()).replace(tzinfo=target)
    return target.fromutc(utc_like)

也就是说,先把源时间归一到 UTC,再根据目标时区规则生成新的本地表示。Python 文档明确区分了这种“转换”和 replace(tzinfo=...) 这种“直接附加标签”。(docs.python.org)


五、DST:夏令时会制造不存在和重复的本地时间

1. DST 的两类边界

DST,即 Daylight Saving Time,夏令时,是某些地区对本地时钟规则进行季节性调整的制度。

转换时会出现两种特殊区间。

春季前拨:时间不存在

假设某时区在凌晨 2 点把时钟拨到 3 点:

01:59:59
03:00:00

那么 02:30 这个本地时间根本没有对应的瞬时点。

秋季回拨:时间重复

假设时钟从 02:00 拨回 01:00:

00:59:59
01:00:00  # 第一次,夏令时
01:59:59
01:00:00  # 第二次,标准时
01:59:59
02:00:00

此时 01:30 对应两个不同的瞬时点。

2. fold:区分重复时间

PEP 495 为 datetime 增加了 fold 属性:

  • fold=0:重复区间的较早一次;
  • fold=1:重复区间的较晚一次。
from zoneinfo import ZoneInfo

la = ZoneInfo("America/Los_Angeles")

first = dt.datetime(
    2020, 11, 1, 1, 0,
    tzinfo=la,
    fold=0,
)

second = first.replace(fold=1)

print(first)
print(first.utcoffset())
print(first.timestamp())

print(second)
print(second.utcoffset())
print(second.timestamp())

输出:

2020-11-01 01:00:00-07:00
-1 day, 17:00:00
1604217600.0
2020-11-01 01:00:00-08:00
-1 day, 16:00:00
1604221200.0

两个对象显示相同的本地时分秒,但时间戳相差 3600 秒。

从 UTC 转换到 ZoneInfo 时,Python 会自动设置正确的 fold

u1 = dt.datetime(2020, 11, 1, 8, tzinfo=dt.UTC)
u2 = dt.datetime(2020, 11, 1, 9, tzinfo=dt.UTC)

print(u1.astimezone(la))
print(u2.astimezone(la))

输出:

2020-11-01 01:00:00-07:00
2020-11-01 01:00:00-08:00

ZoneInfo 对重复时间使用 fold=0fold=1 区分转换前后的偏移。(docs.python.org)

3. fold 不会出现在普通 ISO 字符串中

print(first.isoformat())
print(second.isoformat())

输出:

2020-11-01T01:00:00-07:00
2020-11-01T01:00:00-08:00

这里偏移不同,所以仍可区分。但如果业务只保存:

2020-11-01T01:00:00

那么两个瞬时点都被压缩成了同一个字符串。

更重要的是,isoformat() 通常只保存 UTC 偏移,不保存 ZoneInfo 的 IANA 键名。例如序列化后可以恢复 -08:00,但不能仅凭字符串恢复“这是 America/Los_Angeles 规则下的时间”。


六、日期时间加法:墙上时间和经过时间不是同一个问题

这是日期时间代码中最容易被忽略的边界。

考虑洛杉矶 2020 年秋季回拨:

from zoneinfo import ZoneInfo

la = ZoneInfo("America/Los_Angeles")
start = dt.datetime(2020, 10, 31, 12, tzinfo=la)

next_day = start + dt.timedelta(days=1)
elapsed = (
    next_day.astimezone(dt.UTC)
    - start.astimezone(dt.UTC)
)

print(start)
print(next_day)
print(elapsed)

输出:

2020-10-31 12:00:00-07:00
2020-11-01 12:00:00-08:00
1 day, 1:00:00

为什么加了 timedelta(days=1),UTC 经过时间却是 25 小时?

因为这段代码表达的是:

在同一个本地时区中,把日历日期向后移动一天,并保留本地时钟 12:00。

秋季回拨使这两个本地中午之间实际经过了 25 个小时。

如果业务要求的是严格经过 24 小时,应在绝对时间轴上计算:

start_utc = start.astimezone(dt.UTC)
after_24h = start_utc + dt.timedelta(hours=24)
display = after_24h.astimezone(la)

print(after_24h)
print(display)

输出:

2020-11-01 19:00:00+00:00
2020-11-01 11:00:00-08:00

两种语义分别是:

本地日历语义:2020-11-01 12:00
绝对持续时间语义:经过 24 小时后是 2020-11-01 11:00

因此:

  • “每天当地时间 09:00 执行”是墙上时间语义;
  • “每隔 24 小时执行”是持续时间语义;
  • “从创建时刻起 30 分钟后超时”应使用绝对时间或单调时钟语义。

Python 文档规定,datetime + timedelta 会保留输入对象的 tzinfo,即使对象 aware,也不会在加法操作中自动把时区转换到另一个对象;两个具有相同 tzinfo 的 aware datetime 在减法时,tzinfofold 会被忽略。(docs.python.org)


七、当前时间:datetime.now()time 中的时钟

1. 获取当前 UTC 时间

now = dt.datetime.now(dt.UTC)
print(now)

不要使用已经被弃用的:

dt.datetime.utcnow()

Python 3.12 起,utcnow() 已被标记为弃用,推荐使用:

dt.datetime.now(dt.UTC)

因为 utcnow() 返回的是 naive datetime,容易被后续代码误认为本地时间。(docs.python.org)

2. Unix 时间戳

POSIX 时间戳通常表示从:

1970-01-01 00:00:00 UTC

开始经过的秒数,通常不计闰秒。(docs.python.org)

instant = dt.datetime(
    2026, 9, 1, 12, 0,
    tzinfo=dt.UTC,
)

timestamp = instant.timestamp()
restored = dt.datetime.fromtimestamp(timestamp, tz=dt.UTC)

print(timestamp)
print(restored)

对于 aware datetime,时间戳可以形式化为:

T=(datetime1970-01-01 UTC).total_seconds()T = (\text{datetime} - \text{1970-01-01 UTC}).\text{total\_seconds()}

datetime.timestamp() 对 naive datetime 的解释不同:naive datetime 会被当作系统本地时间,并依赖平台的 C 时间函数。因此,同一个 naive 值在不同机器上可能产生不同时间戳。(docs.python.org)

naive = dt.datetime(2026, 9, 1, 12, 0)

# 只有在明确知道 naive 表示 UTC 时,才可以这样做
timestamp = naive.replace(tzinfo=dt.UTC).timestamp()

3. 用整数避免浮点精度损失

datetime.timestamp() 返回 float。对于日志排序、数据库主键或高精度事件,浮点数可能造成精度边界问题。

time.time_ns() 返回从 epoch 起算的整数纳秒数,可避免 float 表示带来的精度损失。(docs.python.org)

import time

ns = time.time_ns()
milliseconds = ns // 1_000_000
microseconds = ns // 1_000

print(ns)
print(milliseconds)
print(microseconds)

但要注意:

整数纳秒表示的是存储精度,不等于操作系统时钟真的具有纳秒级准确度。

4. 墙上时钟和单调时钟

time.time() 是系统墙上时钟,可能因为 NTP 校时、管理员调整或虚拟机时间同步而向前或向后变化。

超时和耗时测量应使用单调时钟:

import time

begin = time.monotonic()

# 执行某项操作
time.sleep(0.2)

elapsed = time.monotonic() - begin
print(elapsed)

monotonic() 不能转换成现实日期,但保证适合比较同一进程中的时间间隔。可以这样区分:

需求 适合的表示
显示当前时间 aware datetime
保存事件发生时刻 UTC datetime 或整数时间戳
计算业务日历时间 datetime + ZoneInfo
测量耗时和超时 time.monotonic()
性能基准 time.perf_counter()

time 模块将 timemonotonicperf_counterprocess_time 等时钟作为不同语义的时钟提供。(docs.python.org)


八、解析和格式化:isoformatfromisoformatstrftime

1. ISO 8601

ISO 8601 是日期时间交换时常用的文本格式。Python 的 datetime.isoformat() 会生成类似:

2026-09-01T12:00:00+08:00
2026-09-01T04:00:00+00:00

示例:

value = dt.datetime(
    2026, 9, 1, 12, 0, 0, 123456,
    tzinfo=ZoneInfo("Asia/Shanghai"),
)

print(value.isoformat())
print(value.isoformat(timespec="seconds"))
print(value.isoformat(timespec="milliseconds"))

输出:

2026-09-01T12:00:00.123456+08:00
2026-09-01T12:00:00+08:00
2026-09-01T12:00:00.123+08:00

timespec="milliseconds" 是截断,不是四舍五入。(docs.python.org)

2. fromisoformat

text = "2026-09-01T12:00:00+08:00"
value = dt.datetime.fromisoformat(text)

print(value)
print(value.tzinfo)

输出:

2026-09-01 12:00:00+08:00
UTC+08:00

Z 的 UTC 文本也可以解析:

value = dt.datetime.fromisoformat("2026-09-01T04:00:00Z")

print(value)
print(value.tzinfo)

输出:

2026-09-01 04:00:00+00:00
UTC

解析结果中的 +08:00 是固定偏移,并不会自动变成:

ZoneInfo("Asia/Shanghai")

如果应用需要地区时区规则,应单独保存并恢复时区键名。

3. strftimestrptime

strftime 将对象格式化成字符串:

value = dt.datetime(
    2026, 9, 1, 12, 5, 9,
    tzinfo=dt.UTC,
)

print(value.strftime("%Y-%m-%d %H:%M:%S%z"))

输出:

2026-09-01 12:05:09+0000

strptime 按指定格式解析:

parsed = dt.datetime.strptime(
    "2026-09-01 12:05:09",
    "%Y-%m-%d %H:%M:%S",
)

print(parsed)

输出:

2026-09-01 12:05:09

这个结果是 naive,因为输入没有包含时区信息。

对于机器间接口,优先使用明确的 ISO 8601 格式;对于用户界面,才使用面向本地化展示的 strftime。展示字符串和交换格式不应混用。


九、序列化:格式能否完整恢复原语义

1. JSON 默认不支持 datetime

import json

value = {
    "created_at": dt.datetime.now(dt.UTC),
}

json.dumps(value)

会抛出:

TypeError: Object of type datetime is not JSON serializable

JSON 只定义对象、数组、字符串、数字、布尔值和 null 等基本结构,不知道 Python datetime 的类型、时区和 DST 语义。标准库提供 default 参数或 JSONEncoder.default() 让应用自行定义转换。(docs.python.org)

def encode_datetime(obj):
    if isinstance(obj, (dt.datetime, dt.date, dt.time)):
        return obj.isoformat()
    if isinstance(obj, dt.timedelta):
        return obj.total_seconds()
    raise TypeError(f"unsupported type: {type(obj)!r}")

payload = {
    "created_at": dt.datetime(
        2026, 9, 1, 12, 0,
        tzinfo=ZoneInfo("Asia/Shanghai"),
    ),
    "retry_after": dt.timedelta(seconds=30),
}

text = json.dumps(
    payload,
    default=encode_datetime,
    ensure_ascii=False,
)

print(text)

输出类似:

{"created_at": "2026-09-01T12:00:00+08:00", "retry_after": 30.0}

2. 只保存 ISO 字符串的边界

restored = json.loads(text)
created_at = dt.datetime.fromisoformat(restored["created_at"])

print(created_at)
print(type(created_at.tzinfo))

恢复后的时区对象通常是固定偏移时区,而不是原来的 ZoneInfo("Asia/Shanghai")

这在“只关心瞬时点”的场景通常足够,因为:

created_at.astimezone(dt.UTC)

仍能得到正确的 UTC 时刻。

但如果业务还关心:

  • 用户原本选择的地区;
  • 未来规则变化;
  • 本地日历事件;
  • 原始时区名称;
  • 重复时间中的 fold

那么仅保存 ISO 字符串就不够。

3. 用结构化对象保留完整语义

一种更完整的表示方式是同时保存:

{
  "local": "2020-11-01T01:00:00",
  "zone": "America/Los_Angeles",
  "fold": 1
}

解析代码:

def encode_zoned_datetime(value: dt.datetime) -> dict[str, object]:
    if value.tzinfo is None:
        raise ValueError("expected an aware datetime")

    zone = getattr(value.tzinfo, "key", None)

    return {
        "local": value.replace(tzinfo=None).isoformat(),
        "zone": zone,
        "offset": value.utcoffset().total_seconds(),
        "fold": value.fold,
    }


def decode_zoned_datetime(data: dict[str, object]) -> dt.datetime:
    local = dt.datetime.fromisoformat(str(data["local"]))
    zone = data.get("zone")

    if zone:
        return local.replace(
            tzinfo=ZoneInfo(str(zone)),
            fold=int(data.get("fold", 0)),
        )

    offset = dt.timedelta(seconds=int(data["offset"]))
    return local.replace(tzinfo=dt.timezone(offset))

示例:

original = dt.datetime(
    2020, 11, 1, 1, 0,
    tzinfo=ZoneInfo("America/Los_Angeles"),
    fold=1,
)

encoded = encode_zoned_datetime(original)
decoded = decode_zoned_datetime(encoded)

print(encoded)
print(decoded)
print(decoded.timestamp() == original.timestamp())

输出类似:

{'local': '2020-11-01T01:00:00', 'zone': 'America/Los_Angeles', 'offset': -28800.0, 'fold': 1}
2020-11-01 01:00:00-08:00
True

如果系统只需要事件发生时刻,建议保存 UTC:

{
  "occurred_at": "2026-09-01T04:00:00Z"
}

如果系统需要“某地每天某时执行”的业务意图,则应保存本地时间和 IANA 时区,而不是只保存一次转换后的 UTC。


十、定时任务中的时间语义

时间处理和定时调度的关系可以用三个问题区分。

1. 任务是按瞬时点触发,还是按本地日历触发?

瞬时点任务

例如:

2026-09-01T04:00:00Z 执行一次。

可以保存 UTC 时间戳或 UTC ISO 字符串:

run_at = dt.datetime.fromisoformat(
    "2026-09-01T04:00:00+00:00"
)

本地日历任务

例如:

每个工作日,上海时间 09:00 执行。

这不是一个固定的 UTC 时间。业务模型应保存:

schedule = {
    "zone": "Asia/Shanghai",
    "local_time": "09:00:00",
    "weekdays": [0, 1, 2, 3, 4],
}

调度器每次根据日期和 ZoneInfo("Asia/Shanghai") 重新计算下一次瞬时点。

2. DST 边界如何处理?

如果目标时区存在 DST,某个本地计划时间可能:

  • 不存在:春季跳过;
  • 出现两次:秋季重复。

调度器必须明确策略。例如:

不存在的时间:顺延到下一个有效时间
重复的时间:只执行一次
重复的时间:执行两次

不能依赖操作系统或库的隐式选择,因为“执行一次还是两次”是业务语义,而不是单纯的格式转换。

3. 重复执行、锁和补偿

一个可靠的定时任务通常要把以下信息分开:

计划实例 ID
计划本地日期
计划时区
计划本地时间
解析后的瞬时点
执行状态
尝试次数
锁持有者
最后错误

处理流程可以表示为:

flowchart TD
    A[读取计划规则] --> B[计算本地候选时间]
    B --> C{时间是否存在}
    C -- 否 --> D[按策略跳过或顺延]
    C -- 是 --> E{是否重复时间}
    E -- 是 --> F[依据 fold 和策略生成实例]
    E -- 否 --> G[生成唯一实例]
    F --> H[转换为 UTC 瞬时点]
    G --> H
    D --> H
    H --> I[持久化计划实例]
    I --> J[抢占分布式锁]
    J --> K{锁成功}
    K -- 否 --> L[其他执行器处理]
    K -- 是 --> M[执行任务]
    M --> N{执行成功}
    N -- 是 --> O[标记完成]
    N -- 否 --> P[记录失败并按补偿策略重试]

锁只能解决“同一实例是否允许并发执行”,不能解决:

  • 进程执行到一半崩溃;
  • 任务已完成但状态未提交;
  • 网络超时导致结果不确定;
  • 调度器重启后是否补发;
  • DST 重复时间是否生成两个实例。

因此,任务实例应有稳定的幂等键,例如:

job_id + scheduled_local_date + fold

对于不使用 DST 的时区,fold 通常为 0;对于可能重复的本地时间,必须把 fold 纳入实例身份,避免两个不同瞬时点被误判为同一个任务。


十一、常见错误及诊断方式

错误一:混合比较 naive 和 aware

naive = dt.datetime(2026, 9, 1, 12)
aware = dt.datetime(2026, 9, 1, 12, tzinfo=dt.UTC)

naive < aware

排序比较会抛出:

TypeError: can't compare offset-naive and offset-aware datetimes

修复方式不是强行删除时区,而是先明确 naive 的真实语义:

# 如果 naive 实际表示 UTC
naive_as_utc = naive.replace(tzinfo=dt.UTC)

print(naive_as_utc == aware)

错误二:把 replace 当作转换

value = dt.datetime(
    2026, 9, 1, 12,
    tzinfo=ZoneInfo("Asia/Shanghai"),
)

print(value.replace(tzinfo=dt.UTC))
print(value.astimezone(dt.UTC))

输出分别是:

2026-09-01 12:00:00+00:00
2026-09-01 04:00:00+00:00

前者改变了解释,后者保持了瞬时点。

错误三:只保存本地字符串

2026-09-01 09:00:00

这个字符串没有时区,跨机器、跨服务、跨夏令时规则时都可能产生歧义。

至少应保存:

2026-09-01T09:00:00+08:00

如果是地区日历事件,还应保存:

local = 2026-09-01T09:00:00
zone = Asia/Shanghai

错误四:把 timedelta(days=1) 当作 24 小时

在没有 DST 的时区中,两者经常表现相同;在有 DST 的时区中,它们可能不同。

诊断时同时打印本地值和 UTC 值:

def inspect(value: dt.datetime) -> None:
    print("local:", value)
    print("utc:", value.astimezone(dt.UTC))
    print("offset:", value.utcoffset())
    print("fold:", value.fold)
    print("timestamp:", value.timestamp())

如果本地时间看似只变化一天,而 UTC 时间变化了 23 或 25 小时,说明代码遇到了 DST 边界,必须回到业务语义判断这是正确行为还是缺陷。


十二、一个可复用的边界检查函数

对外部输入,建议尽早验证时间类型和语义:

def require_aware(value: dt.datetime) -> dt.datetime:
    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError("datetime must be timezone-aware")
    return value


def to_utc(value: dt.datetime) -> dt.datetime:
    require_aware(value)
    return value.astimezone(dt.UTC)


def parse_utc(text: str) -> dt.datetime:
    value = dt.datetime.fromisoformat(text)

    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError("timestamp must include a timezone offset")

    return value.astimezone(dt.UTC)

使用:

event_time = parse_utc("2026-09-01T12:00:00+08:00")

print(event_time)

输出:

2026-09-01 04:00:00+00:00

这个函数拒绝以下输入:

2026-09-01T12:00:00

因为它无法判断输入属于哪个时区。与其让错误的时间进入数据库或消息队列,再在下游产生难以追踪的偏移,不如在边界处拒绝不完整数据。


结语:先确定语义,再选择类型

Python 日期时间处理可以归纳为四种不同对象:

date       —— 日历日期
time       —— 一天中的墙上时间
datetime   —— 日期与时间,可带时区
timedelta  —— 持续时间

在此基础上,还要区分:

naive datetime        —— 没有足够信息定位到瞬时点
aware datetime        —— 能定位到明确瞬时点
fixed offset          —— 固定 UTC 偏移
ZoneInfo              —— 带历史和 DST 规则的 IANA 时区
timestamp             —— 从 Unix epoch 起算的数值时间
monotonic clock       —— 只用于可靠测量经过时间

工程代码中的关键选择不是“统一使用 UTC”这么简单,而是:

  • 事件发生时刻,使用 aware UTC;
  • 用户本地日历事件,保存本地时间和 IANA 时区;
  • DST 重复区间,明确处理 fold
  • 经过时间和超时,使用单调时钟或 UTC 时间轴;
  • JSON 序列化,明确精度、偏移、时区名称和恢复策略;
  • 定时任务,区分墙上时间计划与固定间隔计划。

当这些语义被明确后,datetimeZoneInfo、时间戳和序列化格式才会成为可组合的工具,而不是一组容易互相误用的 API。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。