快轉到主要內容
  1. 教學文章/

Python zoneinfo 實戰:時區、DST 與排程時間處理完全攻略

·8 分鐘· loading · loading · ·
Python Zoneinfo Datetime Timezone DST Standard-Library
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 60: 本文

一. 前言:時間看起來簡單,直到它跨過時區
#

寫程式處理時間,很容易一開始覺得沒什麼。datetime.now() 一叫,資料庫一存,畫面一顯示,事情好像就結束了。 然後某天使用者在紐約,伺服器在東京,資料庫存 UTC,排程又遇到夏令時間。拍拍君通常會在這種時候默默放下咖啡。 因為問題不是「怎麼取得現在時間」,而是這個時間到底屬於哪個時區、是不是 UTC、要顯示給誰看、遇到 DST 時凌晨兩點到底存在不存在。 Python 3.9 開始內建 zoneinfo,讓我們不用額外安裝 pytz,就能使用 IANA time zone database,例如 Asia/TaipeiAmerica/New_YorkEurope/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,但 tzinfoNone。 比較好的寫法是:

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 至少不會。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 60: 本文

相關文章

Python tomllib 實戰:內建 TOML 解析、設定檔管理與 pyproject.toml 完全攻略
·7 分鐘· loading · loading
Python Tomllib TOML 設定檔 Pyproject.toml
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具
Python match/case:結構化模式匹配完全攻略
·6 分鐘· loading · loading
Python Match Pattern-Matching Python 3.10
Python enum:打造型別安全的常數管理
·5 分鐘· loading · loading
Python Enum 型別安全 設計模式
Python contextlib:掌握 Context Manager 的進階魔法
·7 分鐘· loading · loading
Python Contextlib Context Manager With 資源管理