如果你写过一段时间 Python,大概率会有这种感觉:明明每天都在用time和datetime,真到封装一个时间处理函数、算一个上周一的日期、或者统计一段代码的耗时的时候,反而开始手忙脚乱。尤其是两个模块功能重叠,接口长得又像,time.time()、time.localtime()、datetime.now()、datetime.timestamp()混在一起,脑子稍微不清醒,写出的是能跑的代码,跑出来的结果却总差那么八小时。
这篇文章就是一次完整的复习整理。我尽量不说教科书废话,直接对标日常开发里真正用得到的功能点:时间戳怎么来、格式化怎么写、日期怎么加减、时区怎么处理、性能计时用什么最准,最后还会把我这些年踩过的坑挨个列出来。适合刚学完基础语法、准备系统写工具的初学者,也适合用了一段时间但老要翻文档的人。
1. 先搞清楚两个模块的分工:该用谁,不由心情决定
很多新手纠结time和datetime哪个更好用,其实这俩根本不是一个层面的东西,谈不上谁替代谁。理解它们的分工,比背 API 重要得多。
1.1 time 模块:贴近操作系统的"底层时钟"
time模块可以理解成 Python 对操作系统时间接口的封装。它最核心的概念是“时间戳”,也就是从 1970年1月1日 00:00:00 UTC 到现在经过的秒数,一个浮点数。这个数字没有时区概念,是全球统一的时间基准,所以它非常适合做时间差的数学计算、数据存储和跨系统传输。
time模块内部管理着三类时间表示:时间戳(float)、结构化时间(struct_time)、格式化字符串(str)。它提供的函数基本都是这三者之间的转换工具。它的另一个重要职责是sleep,让程序暂停若干秒,这个也是datetime做不到的。
所以当你的需求是“算耗时”“让程序停一停”“拿到一个纯粹的秒数”时,直接用time,不需要引入datetime。
1.2 datetime 模块:面向业务的"日期管家"
datetime模块则是建立在时间戳之上的面向对象封装,它把时间拆成year、month、day、hour、minute、second、microsecond这些人类可读的属性,并提供date、time、datetime、timedelta、timezone五个核心类。
datetime适合什么场景?比如“算出 30 天后的日期”“判断今天是星期几”“把用户输入的2025-06-01解析成日期对象然后比较大小”。这些操作如果让你拿时间戳硬算,会很痛苦,因为你要自己处理每个月有多少天、闰年怎么算。datetime把这些逻辑都内化了,日期加减直接用timedelta,对比直接用运算符,代码读起来跟自然语言差不多。
一句话总结:跟时间撮打交道用time,跟日历和业务日期打交道用datetime。两个模块在转换处有接口互相衔接,后面会详细说。
2. time 模块核心点:别只记住 time() 和 sleep()
time模块函数不算多,但每个都值得认真理解。我按使用频率从高到低拆开讲。
2.1 三种时间形态的互相转换
time模块的三种时间形态分别是:
- 时间戳:float,
time.time()返回当前时间戳 - 结构化时间:
time.struct_time,一个包含年月日时分秒等 9 个字段的命名元组,time.localtime()返回本地时区的结构化时间,time.gmtime()返回 UTC 时区的结构化时间 - 格式化字符串:
time.strftime(format, struct_time)把结构化时间变成字符串,time.strptime(string, format)把字符串解析成结构化时间
日常最常用的两个组合是:
import time # 时间戳转字符串 timestamp = time.time() struct_time = time.localtime(timestamp) str_time = time.strftime("%Y-%m-%d %H:%M:%S", struct_time) print(str_time) # 字符串转时间戳 parsed = time.strptime("2025-06-01 12:00:00", "%Y-%m-%d %H:%M:%S") timestamp_back = time.mktime(parsed) print(timestamp_back)这里特别要注意:time.localtime()不传参数时默认使用当前时间戳,time.strftime()如果不传第二个参数,默认也是当前时间。这不一定是坏事,但新手容易误以为格式化跟本地时间无关。
第二个要注意的坑是time.mktime()。它接收的是本地时区的结构化时间,如果你把一个 UTC 的 struct_time 拿给它转时间戳,就会多出时区偏移的误差。反过来,time.gmtime()把时间戳转成 UTC 的结构化时间。
2.2 沉睡的精确度问题
time.sleep(seconds)是暂停控制的主要手段,但它的实际休眠时间并不严格等于你传入的秒数。原因在于操作系统的时间片调度,sleep的精度一般在毫秒到几十毫秒级别,而且一旦被信号中断,还可能提前返回。
如果需要高精度的延迟,比如控制硬件或者做动画帧率限制,可以配合time.perf_counter()做补偿循环:
import time def precise_delay(seconds): start = time.perf_counter() while time.perf_counter() - start < seconds: pass这个写法在普通业务里不需要,但在某些对时序敏感的采集程序里能救命。记得不要在生产环境的普通逻辑里用空转循环,会占满 CPU;可以配合time.sleep(0.001)降低空转频率。
2.3 性能计时:perf_counter 才是正解
统计一段代码跑了多久,最直观的写法是:
start = time.time() # do something end = time.time() print(end - start)这个写法能跑,但不够严谨。time.time()返回的是墙上时钟时间(wall clock time),它会受到系统时间调整的影响。比如在计时过程中系统自动校准了时间,或者你手动修改了系统时间,开始和结束的差值就会异常。另外在部分操作系统上,time.time()的精度有限,对于毫秒以下级别的耗时就无能为力。
更可靠的是time.perf_counter(),它专门用于测量短时间间隔,精度极高,并且不受系统时间调整影响,返回的是一个单调递增的计数器。正确写法:
import time start = time.perf_counter() # do something, e.g. 计算列表求和 sum(range(1000000)) end = time.perf_counter() print(f"耗时: {end - start:.6f} 秒")我见过不少性能优化场景,本来要对比两种算法的耗时差异,结果因为用了time.time(),数据噪声很大,最后换到perf_counter才得到稳定结论。建议所有秒级以下的性能测试统一使用perf_counter。
3. datetime 模块实战:核心类与日期运算
datetime模块的类设计非常贴近生活,学起来不难,但有几个细节特别值得展开讲。
3.1 五个类的关系,一张表讲明白
| 类名 | 用途 | 示例 |
|---|---|---|
datetime.date | 日期(年月日) | date(2025, 6, 1) |
datetime.time | 时间(时分秒微秒,无日期) | time(14, 30, 0) |
datetime.datetime | 日期 + 时间 | datetime(2025, 6, 1, 14, 30, 0) |
datetime.timedelta | 时间差(天、秒、微秒) | timedelta(days=7) |
datetime.timezone | 时区对象 | timezone(timedelta(hours=8)) |
五个类里,datetime.datetime是用得最多的,因为它同时包含日期和时间,可以直接从时间戳转换,也可以拆出date和time对象。date对象可以与datetime对象直接比较大小,因为datetime是date的子类。
创建时间对象时注意:datetime.time和datetime.timezone同名但完全是两码事,前者表示“几点几分”,后者表示“时区偏移”。导入时建议写成from datetime import datetime, timedelta, timezone,避免名称冲突。
3.2 timedelta 运算:日期加减的核心玩法
timedelta是datetime模块里最强大的工具之一。你可以直接在datetime或date对象上加减它:
from datetime import datetime, timedelta now = datetime.now() print("当前时间:", now) # 加 3 天 later = now + timedelta(days=3) print("3天后:", later) # 减 1 周 last_week = now - timedelta(weeks=1) print("一周前:", last_week) # 加 2小时30分 delta = timedelta(hours=2, minutes=30) print("2小时30分后:", now + delta)两个datetime对象相减得到timedelta,反之timedelta不能单独存在,必须挂在某个日期上进行运算。
timedelta的构造函数接受days、seconds、microseconds、milliseconds、minutes、hours、weeks七个参数。它内部会统一换算成days + seconds + microseconds存储,所以打印时看到的可能是1 day, 2:03:00这种格式。
有个容易踩的细节:timedelta(days=1)在datetime上加减会同时处理跨月和跨年的情况。也就是说,1月31日加1天会正确变成2月1日,不需要你手动判断当月天数。这个能力在处理月末排期时特别有用。
3.3 格式化输出与字符串解析
datetime对象的格式化同样使用strftime和strptime,只不过调用方式变成了对象方法:
from datetime import datetime now = datetime.now() print(now.strftime("%Y-%m-%d %H:%M:%S")) # 2025-06-01 14:30:00 print(now.strftime("%A, %B %d, %Y")) # Sunday, June 01, 2025 print(now.strftime("%j")) # 年第几天,如 152 parsed = datetime.strptime("2025/06/01 14:30", "%Y/%m/%d %H:%M") print(parsed)常用格式符对应关系,整理成表放在手边备查:
| 格式符 | 含义 | 示例 |
|---|---|---|
%Y | 四位年份 | 2025 |
%y | 两位年份 | 25 |
%m | 两位月份 | 06 |
%d | 两位日期 | 01 |
%H | 24小时制小时 | 14 |
%I | 12小时制小时 | 02 |
%M | 分钟 | 30 |
%S | 秒 | 00 |
%f | 微秒(6位) | 123456 |
%A | 完整星期名 | Sunday |
%a | 缩写星期名 | Sun |
%B | 完整月份名 | June |
%b | 缩写月份名 | Jun |
%j | 年内的第几天 | 152 |
%w | 星期几(0为周日) | 0 |
strptime解析字符串时,格式必须和字符串严格匹配,包括分隔符。多一个空格、少一个前导零都会报错。比如2025-6-1用%Y-%m-%d解析会失败,因为%m和%d默认期望两位数。解决办法是用%-m和%-d(仅 Linux/macOS 支持),或者干脆用dateutil库的parser.parse()做宽松解析。
4. 高频场景实操:把复习变成能用的代码
光看文档记不住,我把日常开发里出现频率最高的几个场景拉出来,直接给可复制的代码。
4.1 时间戳与 datetime 互转的完整映射
datetime与time模块之间的核心桥梁,是datetime.timestamp()方法和datetime.fromtimestamp()类方法:
from datetime import datetime, timezone import time # datetime -> 时间戳 dt = datetime(2025, 6, 1, 12, 0, 0) ts = dt.timestamp() print(ts) # 时间戳 -> datetime(本地时区) dt_local = datetime.fromtimestamp(ts) print(dt_local) # 时间戳 -> datetime(UTC 时区) dt_utc = datetime.fromtimestamp(ts, tz=timezone.utc) print(dt_utc) # 当前时间 now_dt = datetime.now() now_ts = time.time()datetime.now()得到的是本地时区的 naive datetime(不带时区信息),datetime.utcnow()已不推荐使用,官方建议直接用datetime.now(timezone.utc)获取带时区的 UTC 时间。这样做的好处是,后续如果要把数据存进数据库或者做跨时区计算,不会因为缺失时区信息而出错。
4.2 日志文件名与任务调度:格式化实例两则
日志文件的命名是strftime的经典应用场景。按天切分或按小时切分时,通常直接这样写:
from datetime import datetime import os log_filename = datetime.now().strftime("app_%Y%m%d_%H.log") log_path = os.path.join("logs", log_filename) print(log_path) # logs/app_20250601_14.log任务调度则经常用到“某个时间点之后”的逻辑,比如定时任务的抓取窗口:
from datetime import datetime, timedelta def is_in_window(now=None): now = now or datetime.now() start = now.replace(hour=0, minute=0, second=0, microsecond=0) end = start + timedelta(days=1) return start <= now < end print(is_in_window())这里用到了datetime.replace()方法,它可以把对象的某个字段替换成指定值,返回新对象而不修改原对象。这是个非常实用的小技巧,比手动构造datetime(year, month, day, 0, 0, 0)要简洁得多,也避免了时区或闰日的计算疏漏。
4.3 月末日期与上个月:日期平移的正确姿势
业务中经常要算“上个月最后一天”或“本月第几个周五”。如果你用天数硬减,很容易被大月小月坑到。正确姿势是用calendar模块配合datetime:
from datetime import datetime, date import calendar def last_day_of_prev_month(now=None): now = now or datetime.now() first_of_this_month = now.replace(day=1) last_of_prev_month = first_of_this_month - timedelta(days=1) return last_of_prev_month.date() print(last_day_of_prev_month()) # 如果是2025-06-15,返回2025-05-31另一个高频需求是“算上一个工作日”。这个逻辑比较简单粗暴:如果是周一,就回退3天,其余情况回退1天,然后判断是否法定节假日。法定节假日需要接节假日库,这里只给到周末判断:
from datetime import datetime, timedelta def previous_workday(now=None): now = now or datetime.now() days_back = 1 if now.weekday() == 0: # 周一 days_back = 3 return (now - timedelta(days=days_back)).date() print(previous_workday())这里用到了weekday(),它返回 0 代表周一,6 代表周日,和%w格式符(0代表周日)不一样,容易混,写判断时注意区分。
4.4 时间差计算:能否赶上 3 秒超时
在做接口超时判断或任务延迟检测时,标准做法是记录开始时间戳,然后在关键节点比对差值:
from datetime import datetime, timedelta start = datetime.now() # 模拟某操作 import time time.sleep(2.5) elapsed = datetime.now() - start if elapsed > timedelta(seconds=3): print("超时", elapsed.total_seconds()) else: print("按时完成", elapsed.total_seconds())timedelta.total_seconds()返回浮点数,是计算时间差的统一出口。注意elapsed.seconds属性只包含“秒”的部分,不包含“天”,如果时间差超过一天,直接用.seconds会少算一天,必须用.total_seconds()才准确。
5. 常见坑点与排查经验:这些年我踩过的雷
时间处理代码看起来简单,但出问题的时候往往很难排查,因为错误可能只在特定时区、特定日期或者特定字符串格式下暴露。下面这些坑,我一个一个都踩过。
5.1 本地时间与 UTC 混淆:八小时偏差的元凶
最经典的坑就是本地时间和 UTC 时间相差八小时。常见出问题场景:数据库里存的是字符串'2025-06-01 12:00:00',你用datetime.strptime解析后直接比较大小,但这份数据其实是 UTC 时间。如果不先把字符串转成带时区的 UTC 时间,再转成你的本地时间,比较结果就会偏移八小时。
排查建议:所有系统中流转的时间,要么统一用时间戳存储,要么统一用 UTC 时间字符串,并且字段名标注_utc后缀。展示层再转本地时间。
5.2 strftime 与 strptime 的格式错配
有一次我解析一个日志文件,格式是2025-06-01 14:30:05,123,也就是毫秒部分用逗号分隔。我拿着%Y-%m-%d %H:%M:%S,%f去解析,结果总是报错。查了半天才发现,%f期望的是 6 位微秒,而我提供的是 3 位毫秒。解决方法是把毫秒先补成 6 位:
log_time = "2025-06-01 14:30:05,123" log_time_normalized = log_time.replace(",", ".") + "000" parsed = datetime.strptime(log_time_normalized, "%Y-%m-%d %H:%M:%S.%f") print(parsed)还有一次,我用%Y%m%d去解析20250601,完全没问题,但如果字符串里面混进了全角字符或者 BOM 头,就会莫名失败。遇到解析报错,先打印字符串的repr(),看是不是混入了看不见的字符。
5.3 时间格式化踩坑:%m 与 %M 的视觉陷阱
%m是月份,%M是分钟,大小写差距只有一丁点,但含义天差地别。我曾经在生成报表文件名时把%M写成了%m,结果所有文件名都带上了当前月份,而不是当前分钟,导致一天的文件全部重名覆盖,数据丢了一批。后来我给自己定了一条规矩:脚本里所有时间格式化字符串,写完之后先跑一行datetime.now().strftime(...)打印输出确认一下,再投入使用。
5.4 月末日期计算:day=0 技巧
平时用timedelta做加减很方便,但想快速拿到某个月的最后一天,有个冷门又好用的写法:让day参数传 0。Python 的datetime会把day=0解释为“上个月的最后一天”。
from datetime import datetime def last_day_of_month(year, month): if month == 12: return datetime(year, 12, 31).date() else: return datetime(year, month + 1, 1).date() - timedelta(days=1)实际上更简洁的做法是利用calendar.monthrange(year, month)返回(首日星期几, 当月天数),直接取第二个值。两种方法都可以,个人推荐calendar.monthrange,可读性更好。
5.5 性能计时别用 f-string 拼接浮点
写完代码之后,我发现用 f-string 打印perf_counter的差值时,如果直接写f"{end - start}",会输出一长串小数,比如0.002345678901234567。这不是 bug,但观感很差。正确做法是加上格式控制:
print(f"耗时 {end - start:.4f} 秒")另外,不要在计时循环里混入所有无关的 IO 操作。perf_counter测的是时间间隔,如果里面夹了网络请求、文件写入,测出来的数据就不是纯算法耗时了,分析性能时容易误判。
5.6 日期比较:aware 与 naive 不能混用
如果两个datetime对象一个带时区(aware),一个不带时区(naive),直接比较大小会抛出TypeError: can't compare offset-naive and offset-aware datetimes。我自己在做全球时区订单统计时被这个报错卡住过。解决方法是统一转换成带时区的时间再比较:
from datetime import datetime, timezone dt_naive = datetime(2025, 6, 1, 12, 0, 0) dt_aware = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) # 统一为 aware dt_naive_utc = dt_naive.replace(tzinfo=timezone.utc) print(dt_naive_utc < dt_aware) # 或统一为 naive(直接用 timestamp 比较) print(dt_naive_utc.timestamp() < dt_aware.timestamp())养成习惯:业务数据入库前统一转成 UTC 的 aware datetime,展示和运算全用同一个标准,就能少踩一半的时区坑。
6. 一张速查表:日常编写时的提效锦囊
我最后整理一张速查表,覆盖time和datetime日常最高频的编码需求,直接存下来当备忘录用,比翻文档快。
| 需求 | 推荐写法 |
|---|---|
| 当前时间戳 | time.time() |
| 当前时间 datetime(本地) | datetime.now() |
| 当前时间 datetime(UTC) | datetime.now(timezone.utc) |
| 时间戳转 datetime(本地) | datetime.fromtimestamp(ts) |
| 时间戳转 datetime(UTC) | datetime.fromtimestamp(ts, tz=timezone.utc) |
| datetime 转时间戳 | dt.timestamp() |
| 当前格式化字符串 | datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| 字符串解析 | datetime.strptime(s, "%Y-%m-%d %H:%M:%S") |
| 日期加 N 天 | dt + timedelta(days=N) |
| 上个月最后一天 | dt.replace(day=1) - timedelta(days=1) |
| 当月天数 | calendar.monthrange(year, month)[1] |
| 两个日期相差天数 | (d1 - d2).days |
| 高精度性能计时 | time.perf_counter() |
| 毫秒级延迟 | time.sleep(0.001) |
我个人在实际使用中形成的一个习惯是:所有跨模块、跨函数的时间参数,一律用时间戳传递;只有展示和给用户看的时候,才转成字符串或 datetime。这样做能最大程度避免时区污染和格式化出入,也让整个程序的时间脉络更清晰。建议你从这个角度重新过一遍自己的代码,也许能发现不少以前没注意到的隐性问题。
最后再分享一个小技巧:如果你经常要写时间相关的推导式或条件判断,可以在项目里封装一个time_utils.py,把上面这些高频操作截成函数,统一维护格式符和时区设置。时间处理这种代码,每个项目都写一遍也不难,但集中管理之后,遇到坑只用修一处,绝对划算。