日期时间处理是 Python 开发中最常见却也最容易出错的领域之一。本文从字符串解析到跨时区计算,从性能陷阱到企业级最佳实践,给你一个完整答案。
目录
- 为什么日期时间容易出错
- datetime 模块核心概念
- 日期时间对象创建与基本操作
- 格式化字符串:strftime vs strptime
- 时区处理:从 pytz 到 zoneinfo
- timedelta:时间差计算
- dateutil:智能日期解析
- 常见日期场景实战
- 性能优化与陷阱
- 日期处理速查表
1. 为什么日期时间容易出错
在正式学习之前,先看几个常见的坑:
| 陷阱 | 错误示例 | 后果 |
|---|---|---|
| 时区缺失 | datetime.now() 没有时区 | 跨时区系统数据混乱 |
| 字符串拼日期 | "2024-01-" + str(day) | 02 月没有 30 号却拼出 "2024-02-30" |
| 夏令时 | 美国东部 3 月 10 日 2:00 AM | 2024-03-10 02:30 根本不存在 |
| 精度丢失 | 毫秒级时间戳转 float | 精度丢失导致排序错误 |
核心原则:始终使用时区感知的 datetime 对象,永远不要信任没有时区信息的字符串或时间戳。
2. datetime 模块核心概念
Python 的 datetime 模块包含四个主要类型:
| 类型 | 描述 | 示例 |
|---|---|---|
date | 日期(年-月-日) | 2024-08-12 |
time | 时间(时-分-秒-微秒) | 14:30:00.123456 |
datetime | 日期 + 时间的组合 | 2024-08-12 14:30:00 |
timedelta | 两个日期时间的差值 | 3 days, 2:30:00 |
from datetime import date, time, datetime, timedelta
import time as time_module
# 创建各种日期时间对象
today = date.today() # 2024-08-12
now_time = time(14, 30, 0) # 14:30:00
now_dt = datetime.now() # 2024-08-12 14:30:15.123456
3. 日期时间对象创建与基本操作
3.1 获取当前时间
from datetime import datetime, date
# 当前本地时间(无时区信息!)
now_naive = datetime.now()
print(now_naive) # 2024-08-12 14:30:15.123456
# 当前 UTC 时间(仍无时区)
utc_now = datetime.utcnow()
print(utc_now) # 2024-08-12 06:30:15.123456
# ⭐ 推荐:带时区的当前 UTC 时间(Python 3.2+)
from datetime import timezone
utc_aware = datetime.now(timezone.utc)
print(utc_aware) # 2024-08-12 06:30:15.123456+00:00
# 纯日期
today = date.today()
print(f"今天是 {today.year} 年 {today.month} 月 {today.day} 日")
3.2 指定时间创建
from datetime import datetime, date
# 直接构造
dt = datetime(2024, 8, 12, 14, 30, 0)
print(dt) # 2024-08-12 14:30:00
# 从时间戳创建(UTC 秒数)
ts = 1723452600 # 假设的时间戳
dt_from_ts = datetime.fromtimestamp(ts, tz=timezone.utc)
print(dt_from_ts) # 2024-08-12 06:30:00+00:00
# 从时间戳(毫秒)
ts_ms = 1723452600000
dt_from_ms = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
# 纯日期构造
d = date(2024, 8, 12)
3.3 日期比较与替换
from datetime import datetime
dt1 = datetime(2024, 8, 12, 14, 0, 0)
dt2 = datetime(2024, 8, 13, 10, 0, 0)
print(dt1 < dt2) # True
print(dt1 == dt2) # False
# 替换部分字段(返回新对象,原对象不变)
dt_new = dt1.replace(year=2025, hour=9)
print(dt_new) # 2025-08-12 09:00:00
# 转换为 date 或 time
d = dt1.date() # 2024-08-12
t = dt1.time() # 14:00:00
4. 格式化字符串:strftime vs strptime
这是日期时间操作中最常用的两个方法,也是面试高频考点。
4.1 strftime:datetime → 字符串(format)
from datetime import datetime
dt = datetime(2024, 8, 12, 14, 30, 0)
# 常用格式
print(dt.strftime("%Y-%m-%d")) # 2024-08-12
print(dt.strftime("%Y/%m/%d %H:%M")) # 2024/08/12 14:30
print(dt.strftime("%A, %B %d, %Y")) # Monday, August 12, 2024
print(dt.strftime("%c")) # Mon Aug 12 14:30:00 2024 (本地格式)
print(dt.isoformat()) # 2024-08-12T14:30:00(ISO 8601 标准)
4.2 strptime:字符串 → datetime(parse)
from datetime import datetime
date_string = "2024-08-12 14:30:00"
format_string = "%Y-%m-%d %H:%M:%S"
dt = datetime.strptime(date_string, format_string)
print(dt) # 2024-08-12 14:30:00
print(type(dt)) # <class 'datetime.datetime'>
4.3 常用格式化代码速查
| 代码 | 含义 | 示例 |
|---|---|---|
%Y | 四位年份 | 2024 |
%y | 两位年份 | 24 |
%m | 月份(01-12) | 08 |
%b / %B | 月份缩写/全称 | Aug / August |
%d | 日期(01-31) | 12 |
%H | 小时 24 制(00-23) | 14 |
%I | 小时 12 制(01-12) | 02 |
%p | AM/PM | PM |
%M | 分钟(00-59) | 30 |
%S | 秒(00-59) | 00 |
%f | 微秒(000000-999999) | 123456 |
%z | UTC 偏移 | +0800 |
%Z | 时区名称 | CST |
%A / %a | 星期全称/缩写 | Monday / Mon |
%j | 一年中的第几天(001-366) | 225 |
%W | 一年中的第几周(00-53) | 32 |
%c | 本地日期时间表示 | Mon Aug 12 14:30:00 2024 |
4.4 ISO 8601 标准格式(推荐)
from datetime import datetime, timezone
# ISO 格式字符串(推荐用于 API 和存储)
dt = datetime(2024, 8, 12, 14, 30, 0, tzinfo=timezone.utc)
iso_str = dt.isoformat()
print(iso_str) # 2024-08-12T14:30:00+00:00
# 从 ISO 格式解析(Python 3.7+)
parsed = datetime.fromisoformat("2024-08-12T14:30:00")
print(parsed) # 2024-08-12 14:30:00
# 带 Z(UTC 标记)的 ISO 字符串
from datetime import timezone
dt_z = datetime.fromisoformat("2024-08-12T14:30:00Z".replace("Z", "+00:00"))
print(dt_z) # 2024-08-12 14:30:00+00:00
# 更简便的方式(Python 3.11+)
import datetime
try:
dt_z = datetime.datetime.fromisoformat("2024-08-12T14:30:00+00:00")
except ValueError:
pass
5. 时区处理:从 pytz 到 zoneinfo
5.1 为什么需要时区?
假设你记录了一个事件为 2024-08-12 14:30:00。这到底是什么时区?
- 如果是北京时间(UTC+8),对应的 UTC 时间是
06:30 - 如果是纽约时间(UTC-4,夏令时),对应的 UTC 时间是
18:30
没有时区信息的时间叫做 “naive”,有信息的是 “aware”。在生产环境中,永远使用 aware datetime。
5.2 Python 3.9+ 推荐:zoneinfo
Python 3.9 引入了标准库的 zoneinfo,不再需要 pytz:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# 创建带时区的时间
beijing = ZoneInfo("Asia/Shanghai")
dt_bj = datetime(2024, 8, 12, 14, 30, tzinfo=beijing)
print(dt_bj) # 2024-08-12 14:30:00+08:00
# UTC 时间
utc = timezone.utc
dt_utc = datetime(2024, 8, 12, 6, 30, tzinfo=utc)
print(dt_utc) # 2024-08-12 06:30:00+00:00
# 时区转换
dt_ny = dt_bj.astimezone(ZoneInfo("America/New_York"))
print(dt_ny) # 2024-08-12 02:30:00-04:00
# 获取所有可用时区
import zoneinfo
available = list(zoneinfo.available_timezones())
print(f"共有 {len(available)} 个时区") # 约 600 个
print("Asia/Shanghai" in available) # True
5.3 常见时区转换场景
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def convert_timezone(dt_str, from_tz, to_tz, fmt="%Y-%m-%d %H:%M:%S"):
"""将日期时间从一个时区转换到另一个时区"""
# 解析原时间(假设为 from_tz 时区)
naive_dt = datetime.strptime(dt_str, fmt)
# 附加源时区信息
from_zone = ZoneInfo(from_tz)
aware_dt = naive_dt.replace(tzinfo=from_zone)
# 转换到目标时区
to_zone = ZoneInfo(to_tz)
return aware_dt.astimezone(to_zone)
# 示例:视频会议时间通知
meeting_time = "2024-08-12 10:00:00"
# 会议发起者在北京
bj_time = convert_timezone(meeting_time, "Asia/Shanghai", "Asia/Shanghai")
ny_time = convert_timezone(meeting_time, "Asia/Shanghai", "America/New_York")
print(f"北京时间: {bj_time}") # 北京时间: 2024-08-12 10:00:00+08:00
print(f"纽约时间: {ny_time}") # 纽约时间: 2024-08-11 22:00:00-04:00
5.4 夏令时(DST)注意事项
from datetime import datetime
from zoneinfo import ZoneInfo
# 夏令时转换示例:美国东部
eastern = ZoneInfo("America/New_York")
# 3 月 10 日 2:00 AM —— 时钟跳到 3:00 AM("spring forward")
dst_start = datetime(2024, 3, 10, 2, 30, tzinfo=eastern)
print(dst_start) # 实际会变成 03:30:00-04:00
# 11 月 3 日 1:00 AM —— 时钟回拨 1 小时("fall back")
dst_end = datetime(2024, 11, 3, 1, 30, fold=1, tzinfo=eastern)
# fold=1 表示第二次出现的 1:30
6. timedelta:时间差计算
from datetime import datetime, timedelta
now = datetime(2024, 8, 12, 14, 0, 0)
print(f"当前时间: {now}")
# 加减时间
future = now + timedelta(days=7, hours=3)
past = now - timedelta(weeks=2)
print(f"7天3小时后: {future}") # 2024-08-19 17:00:00
print(f"2周前: {past}") # 2024-07-29 14:00:00
# 计算时间差
deadline = datetime(2024, 12, 31, 23, 59, 59)
remaining = deadline - now
print(f"距离年底还有: {remaining.days} 天")
print(f"还有 {remaining.total_seconds()} 秒")
# 时间差格式化
def format_duration(td: timedelta) -> str:
"""将 timedelta 格式化为人类可读的字符串"""
days = td.days
hours, remainder = divmod(td.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
parts = []
if days > 0:
parts.append(f"{days} 天")
if hours > 0:
parts.append(f"{hours} 小时")
if minutes > 0:
parts.append(f"{minutes} 分钟")
if seconds > 0 or not parts:
parts.append(f"{seconds} 秒")
return " ".join(parts) if parts else "0 秒"
print(format_duration(remaining))
实用的日期计算函数
from datetime import datetime, timedelta
def add_working_days(start_date, days):
"""添加指定工作日(跳过周末)"""
current = start_date
added = 0
while added < days:
current += timedelta(days=1)
if current.weekday() < 5: # 周一到周五是 0-4
added += 1
return current
def get_week_range(date):
"""获取指定日期所在周的起始和结束"""
start = date - timedelta(days=date.weekday())
end = start + timedelta(days=6)
return start, end
def get_month_range(year, month):
"""获取指定月的起始和结束"""
from calendar import monthrange
first_day = datetime(year, month, 1)
last_day_num = monthrange(year, month)[1]
last_day = datetime(year, month, last_day_num, 23, 59, 59)
return first_day, last_day
# 使用
today = datetime(2024, 8, 12)
print(f"3个工作日后: {add_working_days(today, 3)}") # 跳过了周末
week_start, week_end = get_week_range(today.date())
print(f"本周: {week_start} 到 {week_end}")
7. dateutil:智能日期解析
标准库的 strptime 需要严格匹配格式。生产环境中输入往往格式不统一,dateutil 可以智能解析多种常见格式。
# pip install python-dateutil
from dateutil import parser
# 自动识别格式
dt1 = parser.parse("2024-08-12 14:30:00")
dt2 = parser.parse("Aug 12, 2024 at 2:30pm")
dt3 = parser.parse("2024-08-12") # 只有日期,时间默认为 00:00:00
print(dt1) # 2024-08-12 14:30:00
print(dt2) # 2024-08-12 14:30:00
print(dt3) # 2024-08-12 00:00:00
# 处理中文格式(自定义解析器)
from dateutil.parser import parserinfo
class ChineseDateParser(parserinfo):
MONTHS = [
("一月", "1月"), ("二月", "2月"), ("三月", "3月"),
("四月", "4月"), ("五月", "5月"), ("六月", "6月"),
("七月", "7月"), ("八月", "8月"), ("九月", "9月"),
("十月", "10月"), ("十一月", "11月"), ("十二月", "12月"),
]
dt_cn = parser.parse("2024年8月12日", parserinfo=ChineseDateParser())
print(dt_cn) # 2024-08-12 00:00:00
# 相对时间(超实用!)
from dateutil.relativedelta import relativedelta
now = datetime(2024, 8, 12)
next_month = now + relativedelta(months=1) # 下月同日
next_month_end = now + relativedelta(months=1, day=31) # 下月末
next_year_jan = now + relativedelta(years=1, month=1, day=1)
print(f"下个月: {next_month}")
print(f"下个月末: {next_month_end}") # 自动处理不同月份天数
print(f"明年 1/1: {next_year_jan}")
8. 常见日期场景实战
场景 1:日志时间戳解析与排序
from datetime import datetime
from dateutil import parser
logs = [
"2024-08-12 14:30:15 [INFO] 服务启动",
"2024/08/12 14:30:10 [INFO] 配置加载完成",
"Aug 12, 2024 2:30:20 PM [WARN] 内存使用 80%",
]
def parse_log_timestamp(log_line):
"""从日志行中提取并解析时间戳"""
# 尝试多种格式
parts = log_line.split(" [", 1)
if len(parts) == 2:
time_str = parts[0]
try:
return parser.parse(time_str)
except ValueError:
pass
return None
parsed_logs = [(parse_log_timestamp(log), log) for log in logs]
parsed_logs.sort(key=lambda x: x[0] or datetime.min)
print("按时间排序的日志:")
for dt, log in parsed_logs:
print(f" {dt}: {log}")
场景 2:生成日期范围报表
from datetime import datetime, timedelta
def date_range(start_date, end_date):
"""生成日期范围迭代器"""
current = start_date
while current <= end_date:
yield current
current += timedelta(days=1)
# 生成 8 月每一天的日报
dt_start = datetime(2024, 8, 1)
dt_end = datetime(2024, 8, 31)
print("8 月日报清单:")
for d in date_range(dt_start, dt_end):
weekday_name = ["一", "二", "三", "四", "五", "六", "日"][d.weekday()]
print(f" {d.strftime('%Y-%m-%d')} 周{weekday_name}")
场景 3:API 接口的时间参数处理
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
def parse_api_datetime(dt_str: str, client_tz: str = "UTC") -> datetime:
"""
解析客户端传来的时间字符串,统一转为 UTC
支持的输入格式:
- ISO 8601: 2024-08-12T14:30:00+08:00
- 简单格式: 2024-08-12 14:30:00
"""
try:
# 尝试解析带时区的 ISO 格式
if "Z" in dt_str:
dt_str = dt_str.replace("Z", "+00:00")
if any(c in dt_str for c in ['+', '-'] if dt_str.index(c) > 10):
dt = datetime.fromisoformat(dt_str)
else:
# 无时区字符串 → 按客户端时区解析
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
dt = dt.replace(tzinfo=ZoneInfo(client_tz))
# 统一转 UTC
return dt.astimezone(timezone.utc)
except Exception as e:
raise ValueError(f"无法解析日期时间: {dt_str},错误: {e}")
# 客户端传来北京时间
client_dt = "2024-08-12 14:30:00"
utc_dt = parse_api_datetime(client_dt, client_tz="Asia/Shanghai")
print(f"客户端时间: {client_dt}")
print(f"UTC 时间: {utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')}")
场景 4:计算年龄与生日提醒
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
def calculate_age(birth_date: date) -> int:
"""精确计算年龄(已过当年生日才算)"""
today = date.today()
age = today.year - birth_date.year
# 如果今年生日还没到,减一岁
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
def days_until_birthday(birth_date: date) -> int:
"""距离下次生日还有多少天"""
today = date.today()
next_birthday = birth_date.replace(year=today.year)
if next_birthday < today:
next_birthday = next_birthday.replace(year=today.year + 1)
return (next_birthday - today).days
# 测试
birth = date(1995, 10, 15)
print(f"年龄: {calculate_age(birth)} 岁")
print(f"距离下次生日: {days_until_birthday(birth)} 天")
9. 性能优化与陷阱
9.1 datetime 对象的性能
import timeit
from datetime import datetime
# strftime vs 字符串拼接
now = datetime.now()
# 方式 1:f-string 拼接(最快)
def use_fstring():
return f"{now.year}-{now.month:02d}-{now.day:02d}"
# 方式 2:strftime(慢但可读性好)
def use_strftime():
return now.strftime("%Y-%m-%d")
print(f"f-string: {timeit.timeit(use_fstring, number=100000):.4f}s")
print(f"strftime: {timeit.timeit(use_strftime, number=100000):.4f}s")
结论:strftime 在单次操作中性能差异可忽略;在高频循环(如处理百万条日志)时考虑用 f-string。
9.2 常见陷阱清单
from datetime import datetime, timezone
# ❌ 陷阱 1:naive 和 aware 混用
dt_naive = datetime(2024, 8, 12)
dt_aware = datetime(2024, 8, 12, tzinfo=timezone.utc)
# dt_naive > dt_aware # TypeError: can't compare offset-naive and offset-aware
# ✅ 解决:统一转为 aware
dt_naive_utc = dt_naive.replace(tzinfo=timezone.utc)
# ❌ 陷阱 2:%Y 和 %y 混淆
# datetime.strptime("24-08-12", "%Y-%m-%d") # ValueError: 24 不是四位年份
# 应该用 %y
# ❌ 陷阱 3:月份和日期超出范围
# datetime(2024, 2, 30) # ValueError: day is out of range for month
# ✅ 安全创建日期
def safe_date(year, month, day):
"""安全创建日期,超出范围返回月末"""
from calendar import monthrange
max_day = monthrange(year, month)[1]
return datetime(year, month, min(day, max_day))
print(safe_date(2024, 2, 30)) # 2024-02-29 00:00:00(自动调整为闰年 29 日)
# ❌ 陷阱 4:时间戳精度丢失
ts = 1723452600.123456789
# float 转 int 会丢失小数部分
ts_int = int(ts) # 丢失了毫秒信息
9.3 Arrow 库:更友好的时间处理
如果你觉得标准库太繁琐,可以使用 Arrow:
# pip install arrow
import arrow
now = arrow.now()
utc = arrow.utcnow()
# 人性化时间
print(now.humanize()) # "a few seconds ago"
print(now.shift(hours=-3).humanize()) # "3 hours ago"
# 时区转换
ny = now.to("America/New_York")
print(ny.format("YYYY-MM-DD HH:mm:ss ZZZ")) # 2024-08-12 02:30:00 EDT
# 解析任意格式
dt = arrow.get("2024-08-12", "YYYY-MM-DD")
print(dt) # 2024-08-12T00:00:00+00:00
# 时间范围
start = arrow.get("2024-08-01")
end = arrow.get("2024-08-31")
for r in arrow.Arrow.range("day", start, end):
print(r.format("YYYY-MM-DD dddd"))
10. 日期处理速查表
常用操作速查
| 需求 | 代码 |
|---|---|
| 获取当前时间 | datetime.now() / datetime.now(timezone.utc) |
| 格式化输出 | dt.strftime("%Y-%m-%d")) |
| 字符串解析 | datetime.strptime(text, "%Y-%m-%d") |
| ISO 格式输出 | dt.isoformat() |
| ISO 格式解析 | datetime.fromisoformat(text) |
| 时区转换 | dt.astimezone(ZoneInfo("Asia/Tokyo")) |
| 加一天 | dt + timedelta(days=1) |
| 加一月 | dt + relativedelta(months=1) |
| 时间差(秒) | (dt2 - dt1).total_seconds() |
| 时间差(天数) | (dt2 - dt1).days |
| 转时间戳(秒) | dt.timestamp() |
| 从时间戳创建 | datetime.fromtimestamp(ts, tz=timezone.utc) |
| 获取星期几 | dt.weekday() 0=周一, dt.isoweekday() 1=周一 |
| 获取是本年第几周 | dt.isocalendar().week |
延伸阅读
- Python 极简入门教程 —— 系统学习 Python 基础
- Python 现代工具链 —— pip、uv 和项目依赖管理
- Python 类型系统与 Pydantic V2 —— 为 API 参数加上日期时间校验
- Python 内置数据结构完全指南 —— 深入理解 Python 的核心数据结构
时间处理没有银弹。标准库
datetime+zoneinfo足以应对 90% 的场景;dateutil在解析非标准格式时非常有用;当你需要更简洁的 API 时,Arrow 是一个好选择。始终记住:存储用 UTC,显示用本地时区。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。