Python 标准库 Day 3:时间、日期与日历
1. 问题引入
继续我们的博客项目。你想加这几个功能:
- 文章发布时间显示为「3 小时前」「2 天前」
- 给「跨时区订阅者」推送邮件——用户在洛杉矶,要在他当地早上 8 点收到,不是你北京的早上 8 点
- 日志里的时间戳都长这样
2026-04-29T10:30:00+09:00,你要解析、过滤、按时间排序 - 性能优化:「这个接口处理一次请求耗时多少毫秒」
- 月度报表:「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()
这段代码的几个关键设计
**now_utc()**** 是单一入口**——所有"获取当前时间"都走这里,保证整个应用一致**parse_log_time**** 强制要求带时区**——不带就 raise,避免后续踩 naive/aware 混用的坑**humanize**** 内部和**now_utc()**比较**——只要传入的 dt 是 aware,对方是哪个时区都能正确比**format_in_tz**** 是"展示层"**——只在最后给用户看的时候转时区
这就是「存储和计算用 UTC,展示时转本地」原则的具体落地。
5. 练习题
理论题
- 解释 naive 和 aware datetime 的区别,举一个混用会出 bug 的场景
time.time()和time.perf_counter()的区别?什么场景用哪个?- 为什么不推荐用
timezone(timedelta(hours=8))表示北京时间? - ISO 8601 时间戳
2026-04-29T10:00:00+09:00表示哪个时刻?换算成 UTC 是几点? 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 的格式更好吧可能
提示:解析每行时间用 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—— 日历查询(用得少但有时神器)
最重要的概念:
- naive vs aware——这一个区分能帮你避开一半时间 bug
- 存 UTC、展示转本地——工程级别的统一原则
**perf_counter**** 测性能,**time**不是用来测性能的**- 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」