文章
合集Python 标准库第 5 / 8 篇

Python 标准库 Day 3:时间、日期与日历

1. 问题引入

继续我们的博客项目。你想加这几个功能:

  1. 文章发布时间显示为「3 小时前」「2 天前」
  2. 给「跨时区订阅者」推送邮件——用户在洛杉矶,要在他当地早上 8 点收到,不是你北京的早上 8 点
  3. 日志里的时间戳都长这样 2026-04-29T10:30:00+09:00,你要解析、过滤、按时间排序
  4. 性能优化:「这个接口处理一次请求耗时多少毫秒」
  5. 月度报表:「2026 年 4 月所有周一都列出来」

每个功能都是个时间相关的小任务,但每个都能写出 bug:

  • 「3 小时前」遇到夏令时切换怎么办?
  • 用户时区怎么存?写 "GMT+8" 行吗?
  • 时间戳带的 +09:00 是什么含义?
  • 用 time.time() 测性能为什么是错的?

今天就把这些一次性搞清楚。

2. 主题讲解

2.1 几个核心概念(最容易混淆的)

概念 A:时间戳(timestamp) vs 日历时间(datetime)

时间戳:从 1970-01-01 00:00:00 UTC 起累计的秒数,是一个纯数字。

1730000000.0   # 这就是个时间戳,意为这一时刻

日历时间:人类能读的「年-月-日 时:分:秒」。

"2024-10-27 09:33:20"

两者表达的是同一时刻,只是格式不同。计算机内部(数据库、文件系统)几乎都用时间戳,只在显示给人看时才转成日历时间。

概念 B:UTC vs 本地时间

UTC:协调世界时,全球统一标准。
本地时间:UTC + 时区偏移。比如北京 = UTC+8,纽约 = UTC-5(夏令时是 UTC-4)。

2026-04-29 10:00:00 这个字符串没有意义——是哪儿的 10 点?北京的还是纽约的?相差 12 小时。

概念 C:naive vs aware datetime(今天最重要的概念)

Python 的 datetime 对象分两种:

  • naive(朴素):只有日期时间,不带时区信息
  • aware(感知):带了时区信息(tzinfo)
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

naive = datetime(2026, 4, 29, 10, 0, 0)
print(naive)              # 2026-04-29 10:00:00       ← 没时区
print(naive.tzinfo)        # None

aware = datetime(2026, 4, 29, 10, 0, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
print(aware)              # 2026-04-29 10:00:00+09:00  ← 带 +09:00
print(aware.tzinfo)        # zoneinfo.ZoneInfo(key='Asia/Tokyo')

为什么这个区分至关重要:


datetime(2026, 4, 29) < aware

Python 强制你区分这两种,避免「拿北京的 10 点和纽约的 10 点直接比较」这种荒谬的 bug。

记忆口诀:

  • 存储和传输用 aware(带时区),消除歧义
  • naive 只在你 100% 确定上下文时用(比如解析"今天的工作时间")
  • 永远不要把 naive 和 aware 混用

2.2 datetime 模块全景

五个类

from datetime import datetime, date, time, timedelta, timezone
类表示例子
date只有日期2026-04-29
time只有时间(一天内的时刻)10:30:00
datetime日期 + 时间2026-04-29 10:30:00
timedelta时间长度(差值)3 天 5 小时
timezone简单时区(固定偏移)UTC+8

datetime 是最常用的,别的是辅助。

创建 datetime


dt = datetime(2026, 4, 29, 10, 30, 0)


datetime.now()                              # 本地时间,naive(不推荐)
datetime.utcnow()                           # UTC 时间,naive,Python 3.12 已弃用


datetime.now(tz=timezone.utc)               # UTC 时间,aware(推荐)
datetime.now(tz=ZoneInfo("Asia/Tokyo"))     # 东京时间,aware(推荐)


datetime.fromisoformat("2026-04-29T10:30:00+09:00")   # ISO 格式,aware(推荐)
datetime.strptime("2026/04/29", "%Y/%m/%d")            # 自定义格式,naive

最佳实践:用 datetime.now(tz=...) 而不是 datetime.now(),永远获取 aware datetime。

格式化 datetime

dt = datetime.now(tz=ZoneInfo("Asia/Tokyo"))


dt.isoformat()      # '2026-04-29T10:30:00+09:00'


dt.strftime("%Y年%m月%d日 %H:%M")   # '2026年04月29日 10:30'

strftime 的格式符(高频几个,记住就行):

%Y  四位年        2026
%m  两位月        04
%d  两位日        29
%H  24小时制小时   10
%M  分            30
%S  秒            00
%A  星期全名       Wednesday
%a  星期缩写       Wed
%z  时区偏移       +0900

strftime = string format time(格式化输出)
strptime = string parse time(解析输入)
两个反向操作,名字像但用途相反。

timedelta(时间差)

from datetime import timedelta

dt1 = datetime(2026, 4, 29, 10, 0)
dt2 = datetime(2026, 4, 30, 12, 30)

diff = dt2 - dt1
print(diff)                    # 1 day, 2:30:00
print(type(diff))              # <class 'datetime.timedelta'>
print(diff.total_seconds())    # 95400.0  ← 总秒数


new_time = dt1 + timedelta(days=3, hours=5)
new_time = dt1 + timedelta(weeks=2)
new_time = dt1 - timedelta(minutes=30)

timedelta 不支持 months 和 years,因为「一个月有几天」是不确定的(28/29/30/31)。要加月份得用第三方库 dateutil.relativedelta,或者自己处理。

2.3 zoneinfo —— 时区处理(Python 3.9+)

时区的"正确表达方式"

错误做法:

"GMT+8"             # 人类看得懂,程序处理时歧义大
timezone(timedelta(hours=8))   # 固定偏移,无法处理夏令时

正确做法:用 IANA 时区名

from zoneinfo import ZoneInfo

ZoneInfo("Asia/Tokyo")        # 东京
ZoneInfo("America/New_York")  # 纽约(自动处理夏令时)
ZoneInfo("Europe/London")     # 伦敦(自动处理 BST)
ZoneInfo("Asia/Shanghai")     # 上海(中国全境)
ZoneInfo("UTC")               # UTC

IANA 时区名格式是 大洲/城市,全球公认标准。这套数据库由 IANA 维护,每年更新(哪个国家改夏令时规则了之类)。

为什么不用固定偏移

考虑一个场景:固定偏移 timezone(timedelta(hours=8)) 不会自动处理夏令时。3 月纽约从 UTC-5 切到 UTC-4 时,你的"北京时间"转换就错了。ZoneInfo("America/New_York") 自动知道纽约什么时候切换。

记忆口诀:「永远用 IANA 时区名,永远不要写偏移量」。

时区转换

from datetime import datetime
from zoneinfo import ZoneInfo


beijing = datetime(2026, 4, 29, 10, 0, tzinfo=ZoneInfo("Asia/Shanghai"))


ny = beijing.astimezone(ZoneInfo("America/New_York"))
print(ny)   # 2026-04-28 22:00:00-04:00   ← 自动算好夏令时


utc = beijing.astimezone(ZoneInfo("UTC"))
print(utc)  # 2026-04-29 02:00:00+00:00

astimezone() 是 datetime 对象的方法,只对 aware datetime 有效(naive 没法转,因为没有"原时区"信息)。

2.4 time 模块 —— 主要用来测性能

time 比 datetime 更底层,主要做两件事:

1. 拿时间戳

import time

time.time()           # 当前时间戳(秒),float

2. 性能计时(重点)

不要用 **time.time()** 测性能:


start = time.time()
do_something()
print(time.time() - start)

为什么错?**time.time()**** 拿的是系统时钟(wall clock),系统时钟会被 NTP 同步、用户手动修改**。如果你的代码跑到一半,系统刚好同步了一次时间,回退了 30 秒,你测出的耗时就是负的。

正确做法:用 time.perf_counter()

start = time.perf_counter()
do_something()
elapsed = time.perf_counter() - start
print(f"耗时 {elapsed:.4f} 秒")

perf_counter 是单调时钟(monotonic clock)——保证只增不减,专为测时间间隔设计。它不代表"现在是几点",只代表"自某个起点的累计纳秒数",所以系统时钟变化不影响它。

函数用途单调?
time.time()拿当前时间戳("现在几点")否
time.perf_counter()测耗时是
time.monotonic()测耗时(更早的版本,精度低些)是
time.sleep(n)睡 n 秒—

记住:

  • 想知道"几点了" → time.time() 或 datetime.now(tz=...)
  • 想测"花了多久" → time.perf_counter()

2.5 calendar —— 日历计算(用得少但有时神器)

import calendar


calendar.isleap(2024)   # True
calendar.isleap(2025)   # False


calendar.monthrange(2026, 4)   # (2, 30)


print(calendar.month(2026, 4))


for week in calendar.Calendar().monthdays2calendar(2026, 4):
    for day, weekday in week:
        if day != 0:   # 0 表示空白格(不属于本月)
            print(day, weekday)

calendar 在「做月历组件」「找下一个工作日」「批量处理某月数据」时很顺手。

2.6 常见误区一次说清

误区 1:**datetime.now()**** 是 UTC 时间**

不是。它是本地时间,且是 naive(没时区)。要 UTC 用 datetime.now(tz=timezone.utc)。

误区 2:用字符串存时间够了

字符串没有时区也能存,但比较和运算极易出错。最佳实践:

  • 数据库里:存 UTC 时间戳或 aware datetime
  • 文件 / API:存 ISO 8601 字符串(带时区偏移),如 2026-04-29T10:00:00+09:00
  • 显示给用户:转成用户本地时区再格式化

误区 3:所有时间都用本地时间

服务器在北京,用户在纽约,全用本地时间会乱。统一原则:

  • 存储和传输 → UTC
  • 展示 → 转成用户时区

**误区 4:用 ****pytz**

pytz 是 Python 3.9 之前的事实标准,但 API 反人类(要用 pytz.timezone(...).localize(dt) 而不是 dt.replace(tzinfo=...))。**Python 3.9+ 用 ****zoneinfo**,标准库自带,API 直观。

误区 5:闰秒

地球自转不均匀,会偶尔加一秒(闰秒)。理论上 23:59:60 是合法的。但 Python 的 datetime 不支持秒数到 60,处理闰秒要用第三方库或 OS 配合。绝大多数应用不需要管闰秒——Google 等大公司用"涂抹"(leap smear)方式处理,把这一秒分摊到 24 小时里。

3. Maybe Useful 旁注

旁注 1:为什么 1970-01-01 是起点:Unix 时间戳的起点叫"epoch"。1970 是 Unix 系统设计时的"现在",没什么神秘的——就是个工程上的约定。但这导致了 2038 问题:32 位 int 存不下 2038-01-19 之后的秒数,会溢出。所以现代系统都用 64 位时间戳。

旁注 2:**pytz**** 的命名空间灾难**:pytz.timezone("Asia/Shanghai") 返回的对象,不能直接 dt.replace(tzinfo=...) 用——会得到错误的 LMT(local mean time,1900 年代的时区)。必须用 tz.localize(dt)。这个坑害了无数人。zoneinfo 的设计就是要修这个。

旁注 3:MySQL 的 datetime 字段没时区:MySQL 的 DATETIME 类型不存时区,PostgreSQL 的 TIMESTAMP WITH TIME ZONE 才存。用什么数据库决定你的存储策略。一般推荐:MySQL 全部存 UTC,应用层转换;PostgreSQL 直接用 timestamptz。

旁注 4:JavaScript 的时间地狱:new Date("2024-04-29") 在不同浏览器的解析行为都不一样。这就是为什么 JS 圈出了 moment.js / date-fns / dayjs 一堆库。Python 这边相对统一,已经算好的了。

旁注 5:面试高频题:

  • "naive 和 aware 区别?" → 必答
  • "怎么测一段代码耗时?" → 必答 perf_counter
  • "时区怎么存?" → 必答 IANA 名 + UTC
  • "夏令时会带来什么 bug?" → 加分项
  • "Unix 时间戳是什么?epoch 是什么?" → 加分项

旁注 6:日志时间戳的标准格式:用 ISO 8601,带时区偏移:

2026-04-29T10:30:45.123456+09:00

这个格式 Python datetime.fromisoformat 直接能解析,全球工具都认。**不要用 ****2026/04/29 10:30:45**,解析麻烦还有歧义。

旁注 7:UNIX 哲学的一个体现:time 模块的函数名(time、sleep、monotonic)都是简短动词或名词。datetime 模块的类名(datetime、timedelta)冗长但准确。接近系统的层用短名字,业务层用长名字——这是 Python 标准库的设计取舍。

4. 代码实践

围绕开头的博客场景,我们写一个博客时间助手:

"""
time_helper.py - 博客时间相关的工具函数
"""
import time
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo


def now_utc() -> datetime:
    """返回当前 UTC 时间(aware)。所有内部时间用这个。"""
    return datetime.now(tz=timezone.utc)

def now_in(tz_name: str) -> datetime:
    """返回指定时区的当前时间。"""
    return datetime.now(tz=ZoneInfo(tz_name))


def parse_log_time(s: str) -> datetime:
    """解析 ISO 8601 时间戳,必须带时区。"""
    dt = datetime.fromisoformat(s)
    if dt.tzinfo is None:
        raise ValueError(f"时间戳 {s} 缺少时区信息")
    return dt


def humanize(dt: datetime) -> str:
    """把 datetime 转成 '3 小时前' 这种文案。"""
    if dt.tzinfo is None:
        raise ValueError("需要 aware datetime")
    
    delta = now_utc() - dt
    seconds = int(delta.total_seconds())
    
    if seconds < 60:
        return f"{seconds} 秒前"
    elif seconds < 3600:
        return f"{seconds // 60} 分钟前"
    elif seconds < 86400:
        return f"{seconds // 3600} 小时前"
    elif seconds < 86400 * 30:
        return f"{seconds // 86400} 天前"
    else:
        return dt.strftime("%Y-%m-%d")  # 太久之前直接显示日期


def format_in_tz(dt: datetime, tz_name: str) -> str:
    """把 aware datetime 转换到指定时区并格式化。"""
    local_dt = dt.astimezone(ZoneInfo(tz_name))
    return local_dt.strftime("%Y-%m-%d %H:%M %Z")


def timeit(func):
    """简易耗时测量装饰器。"""
    from functools import wraps
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()        # ← 用 perf_counter 不是 time
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} 耗时 {elapsed*1000:.2f} ms")
        return result
    return wrapper


if __name__ == "__main__":
    # 当前时间
    print("UTC 现在:", now_utc())
    print("东京现在:", now_in("Asia/Tokyo"))
    print("纽约现在:", now_in("America/New_York"))
    
    # 解析日志时间
    log_time = parse_log_time("2026-04-29T01:30:00+00:00")
    print("\n日志时间:", log_time)
    
    # 转换显示
    print("北京显示:", format_in_tz(log_time, "Asia/Shanghai"))
    print("纽约显示:", format_in_tz(log_time, "America/New_York"))
    
    # 相对时间
    past = now_utc() - timedelta(hours=3, minutes=15)
    print("\n3 小时前发布的:", humanize(past))
    
    # 性能计时
    @timeit
    def slow_func():
        time.sleep(0.05)
        return "done"
    
    slow_func()

这段代码的几个关键设计

  1. **now_utc()**** 是单一入口**——所有"获取当前时间"都走这里,保证整个应用一致
  2. **parse_log_time**** 强制要求带时区**——不带就 raise,避免后续踩 naive/aware 混用的坑
  3. **humanize**** 内部和 **now_utc()** 比较**——只要传入的 dt 是 aware,对方是哪个时区都能正确比
  4. **format_in_tz**** 是"展示层"**——只在最后给用户看的时候转时区

这就是「存储和计算用 UTC,展示时转本地」原则的具体落地。

5. 练习题

理论题

  1. 解释 naive 和 aware datetime 的区别,举一个混用会出 bug 的场景
  2. time.time() 和 time.perf_counter() 的区别?什么场景用哪个?
  3. 为什么不推荐用 timezone(timedelta(hours=8)) 表示北京时间?
  4. ISO 8601 时间戳 2026-04-29T10:00:00+09:00 表示哪个时刻?换算成 UTC 是几点?
  5. datetime.utcnow() 为什么在 Python 3.12 被弃用?应该用什么替代?
我的回答

1、naive 不带时区、aware 带时区2、time()不保险,系统时间会被其他因素影响;perf_counter 是单调时钟(monotonic clock)——保证只增不减,专为测时间间隔设计
3、datetime.fromisoformat("2026-04-29T10:00:00+09:00").astimezone(ZoneInfo("UTC")) 4、我不知道为什么弃用,应该是全面拥抱 aware 吧,用 datetime.now(tz=ZoneInfo("Asia/Tokyo")这种带 ZoneInfo 的格式更好吧可能

### 代码实操题 **题目 A:日志时间过滤器** 给定一个日志文件,每行一条日志,时间戳是 ISO 8601 格式(带时区)。写一个函数 `filter_logs(path, start, end)`,筛选出 `[start, end]` 时间段内的日志。`start` 和 `end` 可以是任意时区的 aware datetime。

提示:解析每行时间用 fromisoformat;比较前不用转时区——aware datetime 比较时 Python 自动处理。

log_file = Path("data.log")
def ilter_logs(path: Path, start: datetime, end:datetime) -> list[str]:

题目 B:跨时区会议提醒器
你和洛杉矶(America/Los_Angeles)、伦敦(Europe/London)、东京(Asia/Tokyo)的同事开会。给定北京时间的会议时间,输出所有人本地的开会时间。要求处理夏令时正确。

提示:先把北京时间构造成 aware datetime,再 .astimezone(...) 到各个目标时区。

题目 C:装饰器版本的计时器(结合 Day 2 知识)
扩展 Day 2 的装饰器知识,写一个 @profile(log_path) 装饰器:把每次调用的函数名、参数、耗时、时间戳,追加写到 log_path 指定的 JSON Lines 文件(每行一个 JSON 对象)。

提示:JSON Lines 就是每行一个独立的 JSON。用 time.perf_counter() 测耗时,用 now_utc().isoformat() 记时间戳。文件用 "a"(追加)模式打开。

思考题

你做的博客有了订阅功能。每个订阅者填了自己的时区(比如 "Asia/Tokyo"),希望每天本地时间早 8 点收到摘要邮件。

你怎么设计调度逻辑?数据库里订阅时间该存什么(UTC?本地时间?时区名?)?如果用户从东京搬到伦敦,他更新了时区——昨天东京已经发过的邮件、今天伦敦也该发吗?这种边界情况怎么处理?

我的回答

这里不做代码的实现,仅作分析:

  • 数据库存什么:
    • 用户的「订阅时间偏好」存「本地时间 + 时区名」(比如 08:00 + Asia/Tokyo),不是 UTC——因为"早上 8 点"是相对用户而言的
    • 上次发送记录存 UTC 时间戳——便于跨时区对比"是否同一天"
  • 调度逻辑:
    • 调度器通常每小时跑一次,找出所有「当前在 UTC 此刻、对应本地时间 = 8 点」的用户并发送
    • 核心代码:current_local = now_utc().astimezone(ZoneInfo(user.tz)),判断 current_local.hour == 8
  • 时区切换的边界:
    • "检测上次发送"思路对,但"同一天"的定义有歧义——是用户本地的同一天还是 UTC 的同一天?建议:「用户当前时区下的本地日期」
    • 极端情况:用户 4/29 早上在东京收完邮件,下午搬到伦敦(伦敦此时是 4/29 早上 0 点)。如果按"伦敦本地日期"判断,4/29 这天还没发过——按规则应该再发一次。这是合理的——用户体验上"在伦敦没收到 4/29 的"确实没毛病。

6. 当天总结

今天学了什么:

四个时间相关库 + 一套思维框架:

  • datetime —— 日期时间运算的核心(5 个类)
  • time —— 底层时间和性能计时
  • zoneinfo —— 时区处理(IANA 时区名 + 自动处理夏令时)
  • calendar —— 日历查询(用得少但有时神器)

最重要的概念:

  1. naive vs aware——这一个区分能帮你避开一半时间 bug
  2. 存 UTC、展示转本地——工程级别的统一原则
  3. **perf_counter**** 测性能,**time** 不是用来测性能的**
  4. IANA 时区名而不是固定偏移——夏令时是会咬人的

需要反复练习:

  • fromisoformat / strftime / strptime 三套字符串↔datetime 的转换
  • 时区转换 astimezone()
  • timedelta 做时间运算

和明天的衔接:

明天 Day 4 讲 collections / heapq / bisect / itertools / functools——数据结构和算法工具。你今天用到的 Counter 就是 collections 的,@wraps 是 functools 的,明天会系统讲。

具体衔接点:

  • 今天 Day 2 写过的 disk_cache 装饰器,明天会看到标准库自带的 lru_cache
  • 今天 humanize() 那种 if/elif 链,明天用 bisect 能写得更优雅
  • 今天 Counter 的更多用法(most_common、subtract)

时间这块概念多但代码量不大,理解 naive/aware 和 UTC/本地这两组对立就抓住了七八成。有疑问继续问,OK 了说「继续 Day 4」