日期时间在处理爬虫数据、交易日历、定时报表的时候,几乎每天都离不开datetime模块。它看着简单,但真正写起来,光“取当前时间”就有好几种写法,时区更是能让你半夜加班。这篇博客会从datetime的核心类讲起,把常用操作和实际业务场景揉在一起,穿插我踩过的坑和处理办法,帮你一次补齐这块拼图。无论你是刚通过 Python 安装教程入门的初学者,还是已经在用 pandas 做数据的开发者,应该都能找到对自己有用的细节。
1. 先认识 datetime 模块的核心类
1.1 五个核心类,各自扮演什么角色
初学 Python 时,很多人会把datetime理解成一个单一类型,其实它是一个模块,里面装着date、time、datetime、timedelta、tzinfo五个核心类。名字看起来像复制粘贴,但分工非常明确:
date:只有年、月、日,比如2025-07-20,不关心这一天里的哪一秒。time:只有时、分、秒、微秒,不关心是哪一天。datetime:日期和时间合体后的“完整瞬间”,日常最常打交道的就是它。timedelta:表示两个时间点之间的间隔,比如 3 天 5 小时 30 分钟。tzinfo:时区信息,它更像一个抽象接口,我们实际使用时通常通过zoneinfo.ZoneInfo或第三方库来实现。
用生活化类比来理解:date是日历上的那一天,time是钟表上的某一刻,datetime是“在 2025 年 7 月 20 日下午 14:30 那一瞬间”的完整表述,timedelta是“距离那个瞬间还有多久”,tzinfo则是“那一瞬间是在哪个时区被人看见的”。
这里有一个容易忽略的细节:datetime是date的子类。也就是说,凡是能接受date对象的地方,通常也能接受datetime对象,但反过来不行。在做函数接口设计时,如果你只关心日期,就不要把datetime当参数类型过度收窄,否则调用者容易在你这里收到类型错误。
实际项目中,最常用的还是datetime.datetime和datetime.timedelta。数据入库、导出报表、定时任务的逻辑,基本都是围着这两个类转。单独的date和time,更多用在明确“只关心生日”“只关心闹钟时间”这种特殊业务里,字段含义会更清晰。
1.2 naive 与 aware:理解时区的第一步
datetime对象还有一个容易被初学者忽视的分裂属性:naive(朴素时间)和 aware(感知时间)。naive 对象只记录年、月、日、时、分、秒,不记录时区;aware 对象则携带tzinfo,知道自己是哪个时区。
为什么要这么分?两个 naive 对象相减,严格按照墙上时钟的数字来算,也就是“本地钟表时间差”。比如23:00减07:00得到 16 小时,但它没有回答“这两个 7 点和 23 点分别是在哪国看到的”。两个 aware 对象相减,会先换算成 UTC 再计算间隔,结果才代表真实的物理时间流逝。
我在一个跨地区协作项目里踩过这个坑。研发同事在北京,运营同事在海外,大家把时间往数据库里一存,都以为是“世界统一时间”。结果脚本统计某个请求的耗时,算出来经常是负数。后来定位到原因:海外机器上的datetime.now()是服务器本地时区,北京机器上的也是本地时区,两边 naive 相减完全没对齐。
新手可以先记住一条原则:在写入数据库、做时间计算前,先统一时区口径,要么所有对象都带tzinfo,要么明确“这是某时区的本地时间”并尽早完成转换。第四章我会专门展开讲。
2. 上手必会:日期时间的获取、解析与格式化
2.1 获取“现在”并注意 utcnow 的坑
获取当前时间是入门第一课,常见写法有这几种:
from datetime import datetime now = datetime.now() # 本机当前时间,naive today = datetime.today() # 和 now() 基本一致 utc_now = datetime.utcnow() # 老代码里常见,但不推荐如果你用的 Python 版本在 3.11 及以上,调用datetime.utcnow()时解释器会标记 deprecation warning。原因是utcnow()返回的是 naive 的 UTC 时间,它把“UTC 时区的墙上时间”当成了“没有时区的时间”。一旦有人把这个值拿去和本地时间做比较,结果就会错得莫名其妙。
更安全的替代写法:
from datetime import datetime, timezone utc_now = datetime.now(timezone.utc) # aware 的 UTC 时间 local_now = datetime.now().astimezone() # 带本机时区的 aware 时间astimezone()在不传参数时,会把当前时间转换到系统所在时区,并自动挂上时区信息。这个操作比我以前手动 “取 UTC 再 +8 小时” 要安全得多,因为系统时区会自动根据服务器配置调整,遇到夏令时地区也能少踩几个坑。
在纯 Python 代码里,我一般不会用now()去反复比较“本地时间”,而是统一取 UTC 再转换。如果是在无人值守的服务器上,系统时区可能压根不是业务时区,取now()容易让日志时间看起来对不上,直接用datetime.now(ZoneInfo("Asia/Shanghai"))反而更直观。
2.2 字符串和 datetime 互转:常用格式代码
开发过程中,时间对象最终要变成字符串给人看,或者反过来从字符串里解析成对象。这两个操作对应strftime与strptime:
from datetime import datetime dt = datetime(2025, 7, 20, 14, 30, 0) # 对象转字符串 s = dt.strftime("%Y-%m-%d %H:%M:%S") # 2025-07-20 14:30:00 # 字符串转对象 parsed = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")常用格式占位符贴一张表,建议收藏:
| 占位符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位数年份 | 2025 |
| %m | 两位月份 | 07 |
| %d | 两位日期 | 20 |
| %H | 24 小时制小时 | 14 |
| %M | 分钟 | 30 |
| %S | 秒 | 00 |
| %f | 微秒 | 000000 |
| %z | UTC 偏移量 | +0800 |
| %Z | 时区名称 | CST |
| %A | 星期全称 | Sunday |
| %w | 星期数字(0 为周日) | 0 |
| %j | 一年中的第几天 | 201 |
最容易翻车的地方,是解析时“看起来像,实际不匹配”。比如"2025-7-20"用"%Y-%m-%d"解析会直接 ValueError,因为月份没有补零;"2025-07-20 14:30"用"%Y-%m-%d %H:%M:%S"解析也会报错,因为缺少秒字段。
我的处理习惯是:对外接口和数据库存储统一用 ISO 格式%Y-%m-%dT%H:%M:%S,展示给用户时才转成中文或本地样式。这样双方解析都省心。Python 3.11 以后,datetime.fromisoformat()可以直接解析带时区偏移的 ISO 字符串,速度还更快。
如果你遇到一堆乱七八糟的字符串,比如"July 20, 2025"、"2025/07/20"、"2025年7月20日",直接用dateutil.parser.parse可以少写十几套格式。它牺牲了一点性能,换来的是极佳的容错性。我爬虫处理第三方数据时,基本用它做第一层解析,能过 90% 以上的情况。
2.3 timedelta 加减法:让日期自动跑起来
日期计算最核心的工具是timedelta:
from datetime import datetime, timedelta now = datetime(2025, 7, 20, 18, 0, 0) tomorrow = now + timedelta(days=1) last_week = now - timedelta(weeks=1) in_3_hours = now + timedelta(hours=3)timedelta支持天、秒、微秒、毫秒、分钟、小时、周,可以组合传入,使用起来非常直观。但有几个点需要提醒:
datetime与timedelta相加得到datetime;datetime与datetime相减得到timedelta;timedelta不接受months参数,因为每月的天数不固定,Python 故意不做这个设计。
要计算“三个月后”或“月末”,需要自己包一层函数。比如计算当月最后一天:
from datetime import datetime import calendar def last_day_of_month(dt): year, month = dt.year, dt.month return datetime(year, month, calendar.monthrange(year, month)[1])calendar.monthrange(year, month)返回一个元组,第一个值是当月第一天是星期几,第二个值是当月天数。用第二个值就能组装出月末日期。
再比如判断两个时间是否属于同一天:
a = datetime(2025, 7, 20, 23, 59, 59) b = datetime(2025, 7, 21, 0, 0, 0) print(a.date() == b.date()) # False直接用datetime对象进行比较,时间不一致就会误判;先取.date()再比,才是“同一天”的判断。类似这种边界细节,写报表和自动任务时会频繁遇到。
3. 在真实业务里使用 datetime 的几种典型场景
3.1 爬虫数据的时间清洗与窗口去重
写爬虫时,抓下来的数据多半带时间戳,可格式五花八门。有2025-07-20 14:30:00,也有2025/07/20,还有英文的Jul 20, 2025。第一步不是直接往数据库塞,而是把时间统一成可比较的格式。
我通常这样清洗:
from datetime import datetime from dateutil import parser raw = "July 20, 2025 02:30PM" dt = parser.parse(raw) normalized = dt.strftime("%Y-%m-%d %H:%M:%S")dateutil.parser.parse是第三方库,我做了大量爬虫后越发觉得它好使。它不需要你手写复杂格式就能识别大多数常见写法。但它也有猜错的风险,遇到歧义格式还是建议显式指定格式。
另一个经典问题是窗口去重。比如每天抓一次目标页面,只保留当天发布的新闻。我可以先把发布日期解析成datetime,再与“上次成功抓取时间”比较:
from datetime import datetime last_success = datetime(2025, 7, 19, 12, 0, 0) new_item_time = parser.parse("2025-07-20 09:30") if new_item_time > last_success: # 是新增内容,入库并更新 last_success pass注意,这种比较必须保证两侧时区一致。如果来源是海外站点,务必先转成自己定义的统一时区。比如抓到的时间是纽约时间,我一般会先用ZoneInfo("America/New_York")给它贴标签,再astimezone(timezone.utc)转成 UTC,最后才和数据库里的时间比较。
3.2 量化交易中的时间序列基础
量化交易几乎离不开时间。K 线周期、回测区间、指标计算、信号触发,全部要处理时间轴。pandas的DatetimeIndex就是建立在 Pythondatetime语义之上的时间序列索引。
拿到行情数据后第一件事,通常是让 pandas 把字符串解析成时间戳:
import pandas as pd df = pd.read_csv("ticks.csv", parse_dates=["timestamp"]) df.set_index("timestamp", inplace=True)parse_dates=True背后用的就是 datetime 相关解析能力。之后很多判断都依赖DatetimeIndex的字段属性。比如只保留 9:30 到 10:30 的分钟数据:
mask = ( (df.index.hour == 9) & (df.index.minute >= 30) | (df.index.hour == 10) & (df.index.minute <= 30) ) morning = df[mask]按周期聚合时,resample也依赖时间索引:
weekly = df["close"].resample("W").last()写出前 30 天内涨停过的股票名单,也可以用timedelta做时间过滤:
cutoff = datetime.now() - timedelta(days=30) recent_limit_up = df[df["limit_up_time"] >= cutoff]做策略回测时,尤其要注意 naive 与 aware 的混用。pandas的Timestamp有自己的时区机制,如果用原生datetime去和DatetimeIndex里的时间做比较,有时会触发 “can't compare offset-naive and offset-aware datetimes” 的报错。所以我一般会在数据入口处统一成一个时区,再往下做计算。
3.3 自动报表与数据库查询中的日期槽
自动化办公最常见的需求是:每天凌晨跑一个脚本,把昨天的数据拉出来生成 Excel 报表。datetime在这里要负责两件事:计算窗口边界、格式化成 SQL 参数或文件名。
from datetime import datetime, timedelta today = datetime.now().date() yesterday = today - timedelta(days=1) start_time = datetime.combine(yesterday, datetime.min.time()) end_time = datetime.combine(today, datetime.min.time())datetime.combine(date, time)可以把日期和时间拼成一个datetime。用datetime.min.time()表示 00:00:00,能很清爽地构造出“某天零点”。
查询 Oracle 或 MySQL 时,我把 SQL 写成时间区间:
sql = """ SELECT order_id, create_time FROM orders WHERE create_time >= :start AND create_time < :end """ params = {"start": start_time, "end": end_time}我偏爱>= start 且 < end写法,因为“某天 23:59:59”其实很难取准,用“小于次日零点”则天然包含当天最后一刻,也不会和明天数据重叠。
导出 Excel 时,文件名和展示字段也得靠strftime:
filename = f"订单报表_{yesterday.strftime('%Y%m%d')}.xlsx" display_text = end_time.strftime("%Y年%m月%d日")文件名里的日期我尽量用%Y%m%d而不是%Y-%m-%d,因为短横线在某些上传控件的文件名拼接里可能被误解析,生成日志文件时也更清爽。
3.4 定时脚本里的“每周几”判断
不引入 APScheduler 或 Celery 时,我也常直接在脚本开头写时间判断:
from datetime import datetime import calendar now = datetime.now() if now.weekday() == 4: # 0=周一,4=周五 do_weekly_cleanup() if now.day == calendar.monthrange(now.year, now.month)[1]: do_monthly_summary()weekday()返回 0-6,0 是周一;isoweekday()返回 1-7,1 是周一。我个人更爱isoweekday(),因为“周五 = 5”比“周五 = 4”更直观,代码读起来不容易混淆。
有节假日需求时,本地放一个集合存放假期日期:
holidays = {date(2025, 10, 1), date(2025, 10, 2)} if now.date() not in holidays and now.isoweekday() <= 5: run_market_open_tasks()这种做法的好处是纯函数、可测试、不依赖外部调度服务。缺点是脚本必须依赖操作系统的计划任务来唤醒;但一旦涉及多机分布式调度,那已经是另一个领域了。
4. 时区问题:最容易翻车的地方
4.1 为什么时区无知会害了你
看一个让人后背发凉的例子:
from datetime import datetime appointment = datetime(2025, 7, 20, 9, 0, 0) return_time = datetime(2025, 7, 20, 17, 0, 0) print(return_time - appointment) # 8:00:00这看似没问题。但如果第一个时间是北京早上 9 点,第二个时间是纽约傍晚 5 点,真实间隔就完全不同了。naive 对象把所有时间都当成同一个墙钟时间,一旦系统分布在多个时区,偏差立刻会出现。
我自己线上出过一次小事故:脚本在东京的服务器跑,数据库在北京,我用datetime.now()去和 UTC 基准时间比较,结果差了一个小时,导致凌晨报表少了一批数据。查了很久才发现,now()返回的是服务器本地时间,完全没有时区信息。
所以,凡是涉及多地区时间,或者需要跨系统比较,都建议一律使用 aware 对象。如果团队规约统一“数据库存 UTC”,那就在入库前转成 UTC,展示时再转成本地时间。
4.2 用 zoneinfo 做东八区时间的正确姿势
Python 3.9 之后,标准库里加入了zoneinfo,不需要再为时区单独安装pytz。东八区的时间名是Asia/Shanghai:
from datetime import datetime, timezone from zoneinfo import ZoneInfo cn = ZoneInfo("Asia/Shanghai") local_now = datetime.now(cn) utc_now = local_now.astimezone(timezone.utc)如果从数据库读出来的是一个 naive 的“北京时间字符串”,需要先给它“贴上东八区的标签”,再转成 UTC:
naive_time = datetime(2025, 7, 20, 9, 0, 0) aware_local = naive_time.replace(tzinfo=ZoneInfo("Asia/Shanghai")) utc_value = aware_local.astimezone(timezone.utc)注意replace(tzinfo=...)只是给一个墙上时间挂上时区信息,不改变数值;astimezone()才是真正的时间换算,会把墙上时间调整为对应时区的数字。这两步非常容易混,我见过不少同事在replace和astimezone之间选错方向。
实际团队开发时,我会把允许使用的时区名统一写进配置,例如只允许Asia/Shanghai和UTC。因为时区字符串一旦放开,业务代码里就会出现America/New_York、Europe/London等一堆变体,排查问题时非常痛苦。
4.3 夏令时:看似存在又突然消失的 1 小时
如果你只做国内业务,夏令时可以基本不管,因为中国已经多年不实行夏令时。但只要对接欧美市场、做跨境业务或者处理云服务账单,你就一定会遇到夏令时。
夏令时最典型的特征是时间跳变。每年春季,北美部分地区凌晨 2 点会直接跳到 3 点,这一小时的datetime根本不存在;秋季又会多出一个 2 点,同一个墙钟时间对应两个 UTC 时刻。
用zoneinfo构造这种时刻时要非常小心:
from zoneinfo import ZoneInfo from datetime import datetime ny = ZoneInfo("America/New_York") try: dt = datetime(2025, 3, 9, 2, 30, 0, tzinfo=ny) print(dt) except Exception as e: print("这个时间不存在:", e)有些操作系统时区库会默默把这个 2:30 映射到 3:30,并不会报错。如果你在写日终报表或轮询任务,建议把凌晨业务统一放到 UTC 环境下计算,展示时再转成当地时区,这样可以绕开大多数夏令时难题。
5. 高频报错与排查速查表
5.1 ValueError:格式对不上怎么办
写三个月代码,你会遇到最多的是ValueError: time data '...' does not match format。这个报错核心就是“你的字符串和格式串对不上”。
排查顺序很固定:
- 用
print或者repr把原始字符串原样打出来,看有没有隐藏的前后空格、制表符、换行。 - 对照格式表逐项检查,年份、月份、日期占位符有没有写反。
- 能解析 ISO 字符串时,直接用
datetime.fromisoformat(),别自己拼strptime格式。 - 遇到微秒字段,
%f必须放在最后,并且和%S之间不要加空格。
顺手给一个排查小工具:
raw = "2025-07-20 14:30" fmt = "%Y-%m-%d %H:%M:%S" try: datetime.strptime(raw, fmt) except ValueError as e: print("解析失败,实际字符串:", repr(raw)) print("我用格式:", fmt)repr(raw)会把字符串里的空格和制表符显示出来,很多肉眼看不见的问题一下就暴露了。
5.2 TypeError:naive 和 aware 乱加如
另一个高频报错是:
TypeError: can't subtract offset-naive and offset-aware datetimes原因很直接:一边是 naive,一边是 aware,Python 拒绝直接计算。解决办法不是删掉时区信息,而是把它们统一到同一频率。
如果确实要得到 naive 对象,可以这样:
aware = datetime.now(timezone.utc) naive = aware.replace(tzinfo=None)但我不建议把这个当常规武器。更好的办法是给 naive 对象补上时区标签:
from zoneinfo import ZoneInfo local = ZoneInfo("Asia/Shanghai") naive_local = datetime(2025, 7, 20, 9, 0, 0) aware_local = naive_local.replace(tzinfo=local) result = aware_local - another_aware_time工程上,我会在代码入口处定下规矩:所有从数据库读出的时间都按 UTC 处理;所有用户输入的时间先转成 Python aware 对象再进业务层。时间一旦带上时区标签,这种 TypeError 几乎绝迹。
5.3 大数据量下的解析性能优化
datetime.strptime很方便,但遇到百万行级数据时性能会很吃力。我在处理几百万行交易明细时测试过,逐行strptime比 pandas 向量化慢二十倍不止。
首选方案是让 pandas 批量解析:
import pandas as pd df = pd.read_csv("records.csv", parse_dates=["created_at"]) # 如果列已是字符串: df["created_at"] = pd.to_datetime(df["created_at"])pd.to_datetime底层也是在调用时间解析,但它用 C 级循环批量处理,性能高得多。小数据量无所谓,大数据量别在 Python 层写 for 循环。
如果坚持只用标准库,也有两个技巧:一是用datetime.fromisoformat而不是strptime,因为少了动态解析,速度快一些;二是对重复值做缓存:
cache = {} def fast_parse(s): if s not in cache: cache[s] = datetime.fromisoformat(s) return cache[s]这种缓存适合日志数据里日期重复率高的情况,比如一堆记录都是同一个天。如果每天字符串天差地别,缓存收益有限。
6. 我的个人经验:处理日期时间的“内功心法”
工作做得越久,越觉得datetime的麻烦不是“不会用”,而是“没想清楚”。这一路踩坑,我沉淀出来几条自己的规则。
第一,存储用 UTC,展示用本地。所有数据库字段、日志时间戳、消息队列里的时间,优先使用 UTC 意识时间;只在用户界面、报表输出时才转换成本地时区。这条规则能一次摁灭绝大多数跨时区问题。
第二,尽量不手工拼格式。面向接口和数据库统一用 ISO 8601,也就是YYYY-MM-DDTHH:MM:SS。Python 3.11 以后fromisoformat可以直接读,你在 pandas、JavaScript、Java 里也都能自动解析。展示需要中文时再转一次,不要在链路里混用多种格式。
第三,边界就是魔鬼。统计“昨天”不要写成now() - timedelta(days=1),而要先now().date()退到当天零点,再算窗口;判断是否同一天,先调.date();算月末别靠记天数,用calendar.monthrange。
最后分享一个小技巧:如果你要把datetime作为字典键或放进集合里,尽量用 aware 时间,而且最好统一成 UTC。naive 时间经常让两个“看起来不同”的时刻在换算后变成同一个值,触发奇怪的幂等冲突。我写缓存时都是先统一成 UTC 再存,后来踩坑率明显变低。
datetime不是一个一眼能看透的小模块,但只要肯花半小时把格式、时区、边界这三关打通,后面写脚本会稳得多。这篇里的代码我基本都亲手跑过,踩过的坑也如实写了,希望能帮你省下一些深夜排查的时间。