Zenn
🐍

Pythonアプリケーションでタイムゾーンを扱う方法

2025/04/03に公開

始めに

小ネタです。

Pythonでdatetime型を扱うときに、timezone情報を与えることができます。

from datetime import datetime
from zoneinfo import ZoneInfo

datetime.now(tz=ZoneInfo("UTC"))

簡単にtimezoneに合わせた時刻へ変更できることはメリットではあるのですが、timezoneなし(native datetime)とあり(aware datetime)の時刻を比較すると次のエラーが発生します。

<ExceptionInfo TypeError("can't compare offset-naive and offset-aware datetimes") tblen=1>

一番楽な方法がtimezoneなしでもありでも同様に比較できるよう共通メソッドを用意して、astimezoneで毎回timezoneを追加することです。

@staticmethod
def compare(dt_1: datetime, dt_2: datetime) -> bool:
    dt_1_aware_datetime = to_aware_datetime(dt_1)
    dt_2_aware_datetime = to_aware_datetime(dt_2)
    
    return dt_1_aware_datetime > dt_2_aware_datetime

@staticmethod
def to_aware_datetime(value: datetime) -> datetime:
    if value.tzinfo is None:
        value = value.replace(tzinfo=ZoneInfo("UTC")) 
    return value.astimezone(tz=ZoneInfo("UTC"))

ただ、単純比較で十分な時に共通メソッドをわざわざ呼びたくないので、可能な限りPythonアプリケーション上の処理ロジック上ではこのtimezoneの有無を意識しないですむようにする方法をブログにまとめます。

環境

  • Python
    • 3.13
  • pydantic
    • 2.11.1
  • SQLModel
    • 0.0.24
  • SQLAlchemy
    • 2.0.40

実装

共通

基本的にはdatetimeの生成時にtimezoneも一緒に付与する方法をまとめています。また、共通ロジックとしてtimezoneを付与してUTCに変換するメソッドを使用していきます。

class DatetimeResolver:
    @staticmethod
    def enforce_utc(value: datetime | None) -> datetime | None:
        if value is None:
            return None
        if value.tzinfo is None:
            value = value.replace(tzinfo=ZoneInfo("UTC"))
        return value.astimezone(tz=ZoneInfo("UTC"))

アプリケーション上での生成

基本的にはtzを付与した状態で生成します。テストしやすいように共通処理側で生成しましょう。

from datetime import datetime
from zoneinfo import ZoneInfo

datetime.now(tz=ZoneInfo("UTC"))

APIのRequest変換

pydanticでのマッピングを記載します。

一括適用

継承元でmodel_serializermode=wrapで既存のシリアライズ方法を上書きできます。ここでdatetimeだった場合に処理を割り込ませます。

from pydantic import BaseModel, model_serializer

from src.helper.datetime_resolver import DatetimeResolver


class CustomModel(BaseModel):
    @model_serializer(mode="wrap")
    def serialize_datetime(self, handler, info) -> dict:
         # 標準のシリアライズ処理を実行
         result = handler(self)
    
         # datetimeフィールドを一括変換
         for field_name, value in self.__dict__.items():
             field_type = self.model_fields[field_name].annotation
             if field_type is datetime or isinstance(value, datetime):
                 result[field_name] = DatetimeResolver.enforce_utc(value)
    
         return result

部分的適用

timezoneを付与する型を定義します。pydanticのAfterValidatorを使用することで、マッピングが終わった後にtimezoneを付与できます。

from typing import Annotated
from pydantic import AfterValidator


CustomDatetime = Annotated[
    datetime,
    AfterValidator(DatetimeResolver.enforce_utc),
]

class _TestModel(BaseModel):
    dt: CustomDatetime

DBのマッピング

一括適用

DBに関しては、すべて型定義が必要なようで簡単に横展開できる機能は見つかりませんでした。

部分的適用

データマッピングする型定義をdatetimeではなく、拡張した型定義を使用します。

from sqlalchemy import DATETIME, TypeDecorator
from sqlmodel import SQLModel


class UTCDateTime(TypeDecorator):
    impl = DATETIME

    @override
    def process_result_value(self, value, dialect):
        return DatetimeResolver.enforce_utc(value)

class User(SQLModel, table=True):
    __tablename__ = "users"
    # tzを付与する
    created_at: datetime = Field(sa_column=Column(UTCDateTime))
    # tzを付与しない(デフォルト)
    updated_at: datetime = Field(sa_column=Column(DATETIME))

できなかったこと

SQLModelを利用しているのでpydantic側の型変換定義さえ行えば動くと思っていましたが、実際はそんなことなかったです。

CustomDatetime = Annotated[
    datetime,
    AfterValidator(DatetimeResolver.enforce_utc),
]

class User(SQLModel, table=True):
    __tablename__ = "users"
    created_at: CustomDatetime = Field(sa_column=Column(DATETIME))

ソースコード

こちらでテストをしています。

終わりに

もともと調べ始めたきっかけはフロントからTZなしとTZありの日付で連携されることがきっかけでした。挙動としては、次の流れです。

  1. フロントではdatetimeがTZありの状態で連携される
  2. DBでTZを保存していないので、TZなしの状態をDBに保存
  3. DBから取得した値をTZなしのまま、フロントにdatetimeを返却する
  4. フロントでは受け取ったTZなしのdatetimeをそのまま使用し、別項目で設定した新規のTZありのdatetimeが連携される
  5. バックエンドとしては複数のdatetimeの整合性を確認するため、比較したらエラーが発生した

エラー内容がわかりやすいので解決はしやすいものの、アプリケーションの外側(API, DB)側も含めた根本解決を考えると結構面倒ですので、自分としてはまとめられて満足です。

Discussion

ログインするとコメントできます