一. 前言:時間看起來簡單,直到它跨過時區 #
寫程式處理時間,很容易一開始覺得沒什麼。datetime.now() 一叫,資料庫一存,畫面一顯示,事情好像就結束了。
然後某天使用者在紐約,伺服器在東京,資料庫存 UTC,排程又遇到夏令時間。拍拍君通常會在這種時候默默放下咖啡。
因為問題不是「怎麼取得現在時間」,而是這個時間到底屬於哪個時區、是不是 UTC、要顯示給誰看、遇到 DST 時凌晨兩點到底存在不存在。
Python 3.9 開始內建 zoneinfo,讓我們不用額外安裝 pytz,就能使用 IANA time zone database,例如 Asia/Taipei、America/New_York、Europe/London。
今天這篇會很務實地處理幾個場景:
- 建立有時區的
datetime - naive 與 aware datetime 的差別
- UTC 儲存、本地時區顯示
- ISO 8601 字串交換
- DST 的坑
- 排程與 API 常見寫法
如果你剛看完前一篇 APScheduler 排程文章,這篇可以當成時間正確性的補強。排程工具負責「什麼時候跑」,
zoneinfo負責「這個什麼時候,到底是哪個地方的什麼時候」。 差一點點,bug 可以差一天。
二. 安裝:好消息,標準庫內建 #
zoneinfo 從 Python 3.9 開始內建。只要你的 Python 版本夠新,不需要安裝任何套件。
from zoneinfo import ZoneInfo
先確認版本:
python --version
建議使用 Python 3.11 以上。不是因為 3.9 不能用,而是近幾版的 datetime 體驗更完整,錯誤訊息也比較友善。
如果你在某些極簡環境或 Windows 上遇到找不到時區資料,可以安裝 tzdata:
python -m pip install tzdata
多數 macOS、Linux 環境會直接使用系統提供的 time zone database。Windows 或 container image 則比較常需要 tzdata 當備援。
先寫一個最小範例:
from datetime import datetime
from zoneinfo import ZoneInfo
taipei = ZoneInfo("Asia/Taipei")
now = datetime.now(taipei)
print(now)
print(now.tzinfo)
輸出會長得像這樣:
2026-05-15 12:12:00.123456+08:00
Asia/Taipei
注意最後的 +08:00。這代表它不是一個單純的日期時間,它知道自己在 UTC+8。
這種 datetime 叫做 aware datetime。
三. naive vs aware:不要讓時間失憶 #
Python 的 datetime 大致可以分成兩種。第一種是 naive datetime:
from datetime import datetime
created_at = datetime(2026, 5, 15, 12, 0, 0)
print(created_at)
print(created_at.tzinfo)
輸出:
2026-05-15 12:00:00
None
它只有年月日時分秒,但沒有說這是台北中午、紐約中午,還是 UTC 中午。這就像紙條上寫「明天三點開會」,但沒寫在哪個城市。 第二種是 aware datetime:
from datetime import datetime
from zoneinfo import ZoneInfo
created_at = datetime(
2026,
5,
15,
12,
0,
0,
tzinfo=ZoneInfo("Asia/Taipei"),
)
print(created_at)
print(created_at.tzinfo)
輸出:
2026-05-15 12:00:00+08:00
Asia/Taipei
這個時間就比較完整。它知道自己是台北時間中午十二點。 拍拍君的實務建議很簡單:
- 內部運算盡量使用 aware datetime
- 儲存到資料庫前轉成 UTC
- 顯示給使用者前轉成使用者時區
- 不要在系統邊界傳 naive datetime 我們可以寫一個小工具函式,先把規則固定下來:
from datetime import datetime, timezone
def ensure_aware(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("datetime must be timezone-aware")
return value
def to_utc(value: datetime) -> datetime:
value = ensure_aware(value)
return value.astimezone(timezone.utc)
這種檢查看起來有點囉嗦,但它可以阻止很多「本機正常,部署後炸掉」的時間 bug。尤其是 API、排程、資料同步、報表系統,時間一旦跨系統,就不要靠猜。
四. UTC 儲存,本地顯示 #
很多系統會採用一條簡單規則:資料庫裡存 UTC,畫面上顯示使用者本地時間。這不是唯一選擇,但通常是最不容易混亂的選擇。 例如使用者在台北建立一筆任務:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
taipei = ZoneInfo("Asia/Taipei")
local_time = datetime(2026, 5, 15, 20, 30, tzinfo=taipei)
stored_time = local_time.astimezone(timezone.utc)
print(local_time)
print(stored_time)
輸出:
2026-05-15 20:30:00+08:00
2026-05-15 12:30:00+00:00
這兩個時間代表同一個瞬間,只是顯示方式不同。 如果使用者後來在紐約打開同一筆資料:
new_york = ZoneInfo("America/New_York")
display_time = stored_time.astimezone(new_york)
print(display_time)
可能得到:
2026-05-15 08:30:00-04:00
這裡有三個很重要的觀念。
第一,astimezone() 不是改變事件發生的瞬間,它只是換一個時區表示同一個瞬間。
第二,UTC offset 不等於 time zone。-04:00 只是那一天紐約的 offset,真正的時區是 America/New_York。
第三,不要只存 +08:00 然後以為萬事大吉,因為很多地區有 DST。同一個城市在不同日期 offset 可能會變。
ZoneInfo(“America/New_York”) 知道歷史規則,單純的固定 offset 不知道。
五. ISO 8601:API 裡最常見的交換格式 #
API 很常用 ISO 8601 字串交換時間。Python 可以用 isoformat() 產生:
from datetime import datetime, timezone
created_at = datetime(2026, 5, 15, 12, 30, tzinfo=timezone.utc)
payload = {
"created_at": created_at.isoformat()
}
print(payload)
輸出:
{'created_at': '2026-05-15T12:30:00+00:00'}
讀回來可以用 fromisoformat():
from datetime import datetime
value = datetime.fromisoformat("2026-05-15T12:30:00+00:00")
print(value)
print(value.tzinfo)
輸出:
2026-05-15 12:30:00+00:00
UTC
如果你收到的是尾巴帶 Z 的格式:
2026-05-15T12:30:00Z
新版 Python 可以直接處理。如果要兼容比較舊的版本,可以先轉成 +00:00:
from datetime import datetime
def parse_api_time(value: str) -> datetime:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
return datetime.fromisoformat(value)
但解析完不要就算了,建議立刻檢查它是不是 aware:
def parse_aware_api_time(value: str) -> datetime:
parsed = parse_api_time(value)
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError("API time must include timezone")
return parsed
這個錯誤應該越早爆越好。不要讓 2026-05-15T12:30:00 這種沒時區的字串一路混進資料庫。
它看起來像時間,但其實少了最關鍵的上下文。
六. DST:最容易被低估的坑 #
DST,也就是 daylight saving time,中文常叫夏令時間。台灣現在沒有 DST,但很多使用者、雲端服務、團隊成員、客戶所在的地區有。
例如紐約在某些日期會從 UTC-5 變成 UTC-4。我們看看 zoneinfo 怎麼處理:
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
winter = datetime(2026, 1, 15, 9, 0, tzinfo=ny)
summer = datetime(2026, 7, 15, 9, 0, tzinfo=ny)
print(winter)
print(summer)
輸出大概會是:
2026-01-15 09:00:00-05:00
2026-07-15 09:00:00-04:00
同樣是紐約早上九點,offset 不一樣。 所以「使用者每天早上九點收到通知」和「每 24 小時通知一次」不是同一件事。前者是 wall time,後者是 duration。 如果你做的是提醒、日曆、例行報表,通常使用者想要 wall time,也就是「他所在地的早上九點」。 如果你做的是 token 過期、快取 TTL、背景 retry,通常你要的是 duration,也就是「精準經過多少秒」。 這兩種不要混在一起。使用者感知的本地時間要用時區規則計算,精準秒數才適合用 duration。這比在同一段程式裡到處加一天安全得多。
七. 常見實戰:使用者時區設定 #
假設我們正在做一個提醒系統,使用者可以設定自己的時區。資料可能長這樣:
user = {
"name": "拍拍醬",
"timezone": "Asia/Taipei",
"daily_report_hour": 9,
}
我們想把「下一次報表時間」轉成 UTC 存進資料庫。
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
def build_daily_report_time_utc(
target_date: date,
hour: int,
user_timezone: str,
) -> datetime:
local_tz = ZoneInfo(user_timezone)
local_time = datetime(
target_date.year,
target_date.month,
target_date.day,
hour,
0,
tzinfo=local_tz,
)
return local_time.astimezone(timezone.utc)
next_run = build_daily_report_time_utc(
target_date=date(2026, 5, 15),
hour=user["daily_report_hour"],
user_timezone=user["timezone"],
)
print(next_run)
這樣資料庫裡可以存 UTC。使用者畫面要顯示時,再轉回去:
def format_for_user(value: datetime, user_timezone: str) -> str:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("value must be aware")
local_value = value.astimezone(ZoneInfo(user_timezone))
return local_value.strftime("%Y-%m-%d %H:%M %Z")
print(format_for_user(next_run, "Asia/Taipei"))
print(format_for_user(next_run, "America/New_York"))
這裡有一個常見設計決策:要不要存使用者當下的時區?
例如一個事件被建立時,使用者時區是 Asia/Taipei,之後他改成 America/New_York。舊事件要跟著改顯示嗎?
答案看產品需求。會議、航班、課程這種事件,通常要保留事件本身的所在地或原始時區。個人每日提醒,通常跟著使用者目前時區走比較合理。
程式碼解不了產品語意,但程式碼可以讓語意不要被偷換。
八. 跟排程工具搭配時要注意什麼 #
排程工具很多。你可能用 cron,可能用 APScheduler,也可能用雲端平台自己的 scheduler。原則大致一樣。 第一,清楚決定排程語意:是 UTC 的固定時間,還是使用者本地時間? 第二,不要把 naive datetime 丟進排程系統。如果工具接受 timezone 參數,就明確設定。 例如概念上可以這樣:
from zoneinfo import ZoneInfo
user_tz = ZoneInfo("Asia/Taipei")
# 概念示意:不同排程工具 API 會略有不同
scheduler_timezone = user_tz
第三,資料庫裡保留足夠資訊。只存 next_run_at = 2026-05-15T01:00:00Z 有時不夠。
你可能還需要存:
- 使用者設定的 time zone name
- 使用者想要的本地 hour/minute
- 下一次實際執行的 UTC datetime
- 排程規則版本 例如:
daily_rule = {
"timezone": "America/New_York",
"local_hour": 9,
"local_minute": 0,
"next_run_at_utc": "2026-05-15T13:00:00+00:00",
}
這樣即使 DST 規則讓下一次 UTC 時間變動,你也能重新計算。 不要只靠上一次的 UTC 時間加 24 小時。那是 duration,不是 daily wall time。 這句話值得貼在螢幕旁邊。至少貼到你遇過一次 DST bug 為止。
九. 常見錯誤整理 #
下面這幾種寫法很常見,但拍拍君不推薦。 第一個:
from datetime import datetime
created_at = datetime.utcnow()
datetime.utcnow() 回傳的是 naive datetime。它看起來像 UTC,但 tzinfo 是 None。
比較好的寫法是:
from datetime import datetime, timezone
created_at = datetime.now(timezone.utc)
第二個:
from datetime import datetime
created_at = datetime.now()
這會使用系統本地時區,但回傳 naive datetime。你的 laptop、CI、container、server 可能不是同一個時區。 比較好的寫法是:
from datetime import datetime
from zoneinfo import ZoneInfo
created_at = datetime.now(ZoneInfo("Asia/Taipei"))
或更常見:
from datetime import datetime, timezone
created_at = datetime.now(timezone.utc)
第三個:
from datetime import timezone, timedelta
tz = timezone(timedelta(hours=-4))
這不是錯,但它只是固定 offset。如果你要表達紐約,應該用:
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/New_York")
因為紐約不永遠是 -04:00。
第四個:
deadline = "2026-05-15 12:00:00"
這種字串不適合跨系統傳遞。至少使用 ISO 8601,並包含 offset:
deadline = "2026-05-15T12:00:00+08:00"
更穩的是內部統一轉 UTC:
deadline = "2026-05-15T04:00:00+00:00"
十. 一個小型工具模組 #
最後整理成一個可以放進專案的 time_utils.py。
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def require_aware(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("datetime must be timezone-aware")
return value
def as_utc(value: datetime) -> datetime:
value = require_aware(value)
return value.astimezone(timezone.utc)
def parse_api_datetime(value: str) -> datetime:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
parsed = datetime.fromisoformat(value)
return require_aware(parsed)
def to_user_timezone(value: datetime, timezone_name: str) -> datetime:
value = require_aware(value)
return value.astimezone(ZoneInfo(timezone_name))
def format_user_datetime(value: datetime, timezone_name: str) -> str:
local_value = to_user_timezone(value, timezone_name)
return local_value.strftime("%Y-%m-%d %H:%M %Z")
簡單測一下:
created_at = parse_api_datetime("2026-05-15T12:30:00Z")
print(as_utc(created_at))
print(format_user_datetime(created_at, "Asia/Taipei"))
print(format_user_datetime(created_at, "America/New_York"))
測試時間工具很值得。因為時間 bug 常常不是語法錯,而是語意錯。語意錯如果沒測試,通常會等到使用者跨時區或 DST 那天才浮出來。 那天你通常不會很快樂。
結語 #
zoneinfo 不是一個華麗的工具,它不會讓你的程式看起來比較炫,但它可以讓時間這件麻煩事變得可控。
今天的重點其實可以濃縮成幾句話:
- 不要在系統邊界傳 naive datetime
- 內部盡量使用 aware datetime
- 資料庫常用 UTC 儲存
- 顯示時再轉成使用者時區
- time zone name 比固定 offset 更有語意
- wall time 和 duration 是兩種不同需求
時間處理最可怕的地方,是錯了也常常看起來很正常。所以拍拍君會建議你早一點把規則寫進工具函式,不要每個功能各自理解一次。
時間這種東西,真的不適合靠默契。默契會放假,
zoneinfo至少不會。