【Python】Webアプリ開発におけるタイムゾーンの扱い方・注意点（datetime / zoneinfo）

## 本記事のポイント

Datetime型にはタイムゾーン情報を付けましょう。とりあえず以下のような関数を使うのが良いかと思います。

```py
from datetime import datetime
from zoneinfo import ZoneInfo

def get_utc_now():
    return datetime.now(ZoneInfo("UTC"))

def get_jst_now():
    return datetime.now(ZoneInfo("Asia/Tokyo"))

def parse_str_as_jst(dt_str, dt_format, dt_timezone="Asia/Tokyo"):
    return datetime.strptime(dt_str, dt_format).astimezone(ZoneInfo(dt_timezone))
```


## はじめに

アプリケーションをサーバーで動かすとなれば、タイムゾーンの管理が問題となります。仮に日本国内でサービス提供するとしても、サーバー時刻がUTCであれば考慮が必要になります。そんな日時オブジェクトの扱い方・注意点についてまとめました。

Pythonの標準ライブラリである[datetime](https://docs.python.org/ja/3.13/library/datetime.html)を中心にまとめていきます。また、タイムゾーン情報を扱うライブラリには[zoneinfo](https://docs.python.org/ja/3/library/zoneinfo.html)や[pytz](https://pypi.org/project/pytz/)が存在します。zoneinfoが標準ライブラリであるため、こちらを利用します。（Python3.9で追加された）

## Native / Aware datetimeの違い

日時オブジェクトは2種類存在します。両者の違いは、

- Native datetime：
    - タイムゾーン情報を「持たない」日時オブジェクト
- Aware datetime：
    - タイムゾーン情報を「持つ」日時オブジェクト

たったこれだけのことですが、基本的にはAware datetimeを使うほうが安全でしょう。

## 実行環境に依存しないdatetime.now()

まず、現在時刻を取得する方法である`datetime.now()`は、実行環境のシステム時刻に依存します。したがって、サーバーを借りてシステムを稼働させるときには、タイムゾーン情報を指定して現在時刻を取得するなどの対応が必要です。

```py
from datetime import datetime, timezone
import time
from zoneinfo import ZoneInfo

# システム設定のタイムゾーン情報を取得する
print(time.tzname)

# 環境依存
print(datetime.now())

# UTCで固定して取得する
print(datetime.now(timezone.utc))
print(datetime.now().astimezone(timezone.utc))

# 東京時間で取得する
print(datetime.now(ZoneInfo("Asia/Tokyo")))

# ('東京 (標準時)', '東京 (夏時間)')
# 2024-12-26 22:10:00.000000
# 2024-12-26 13:10:00.000000+00:00
# 2024-12-26 13:10:00.000000+00:00
# 2024-12-26 22:10:00.000000+09:00
```

## 文字列からパースする（タイムゾーン付き）

文字列から日時オブジェクトを作るとき、`datetime.strptime()`を使うことが多いと思いますが、これによって生成されるのはNative datetimeです。

```py
from datetime import datetime
from zoneinfo import ZoneInfo

JST_ZONEINFO = ZoneInfo("Asia/Tokyo")
date_str = "2024-12-26 15:00:00"

# Native
date_native = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
print(date_native)

# Aware
date_aware = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=JST_ZONEINFO)
print(date_aware)

# 2024-12-26 15:00:00
# 2024-12-26 15:00:00+09:00
```

## 文字列へフォーマットする（タイムゾーン付き）

こちらも`strftime()`を使うことが多いかと思いますが、避けるほうが良いかもしれません。

```py
dt = datetime.now().replace(tzinfo=ZoneInfo("UTC"))

print(dt.strftime("%Y-%m-%d %H:%M:%S"))
print(dt.isoformat()) # ISO 8601

# 2024-12-26 22:10:00
# 2024-12-26T22:10:00.000000+00:00
```

ちなみに、例えばTypeScriptでこれをパースするには、以下のコードで十分です。（PythonでAPIを立てて、フロントエンドにTSを使うシーンを想定）標準化されたフォーマットに乗っかるほうが、機械可読性が高いので適切に使い分けたいです。

```ts
const isoString: string = "2024-12-26T15:00:00+09:00";
const date: Date = new Date(isoString);
```

## `.replace()`と`.astimezone()`の違い

どちらも日時オブジェクトにタイムゾーン情報を付与する・書き換えるときに使うものですが、挙動が少し異なります。

`replace()`は単純に`tzinfo`（タイムゾーン）を書き換えるだけで、時差の計算は行われません。Native datetimeにタイムゾーン情報を付与するときに使うことが多いと思います。

`astimezone()`はタイムゾーン情報を書き換えて、時差の計算も行います。Aware datetimeのみで利用可能です。

```py
from datetime import datetime
from zoneinfo import ZoneInfo

# 現在時刻（UTC）を取得
utc_now = datetime.now(ZoneInfo("UTC"))
print(f"UTC: {utc_now}")

# replace()
wrong_tokyo = utc_now.replace(tzinfo=ZoneInfo("Asia/Tokyo"))
print(f"replace: {wrong_tokyo}")

# astimezone()
correct_tokyo = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))
print(f"astimezone: {correct_tokyo}")

# UTC: 2024-12-26 12:10:00.000000+00:00
# replace: 2024-12-26 12:10:00.000000+09:00
# astimezone: 2024-12-26 21:10:00.000000+09:00
```

## 時刻比較における注意点

また、時刻を比較するときにAwareとNativeで異なる場合にエラーが発生します。こちらも頭の片隅においておくと良いかもしれません。

```py
print(datetime.now() > datetime.now())
print(datetime.now(timezone.utc) > datetime.now(timezone.utc))
print(datetime.now() > datetime.now(timezone.utc))

# False
# False
# ---------------------------------------------------------------------------
# TypeError                                 Traceback (most recent call last)
# Cell In[6], line 3
#       1 print(datetime.now() > datetime.now())
#       2 print(datetime.now(timezone.utc) > datetime.now(timezone.utc))
# ----> 3 print(datetime.now() > datetime.now(timezone.utc))
#
# TypeError: can't compare offset-naive and offset-aware datetimes
```

## テストにおけるタイムゾーンの指定

テストコードでもタイムゾーンの固定をする場合には、`freezegun`を使います。

インストール：`pip install freezegun`

```py
import unittest
from datetime import datetime
from freezegun import freeze_time
from zoneinfo import ZoneInfo

class TestDateTime(unittest.TestCase):
    # クラスメソッドに対して
    @freeze_time("2024-12-26 15:00:00", tz_offset=9)
    def test_fixed_time(self):
        current = datetime.now(ZoneInfo("Asia/Tokyo"))
        self.assertEqual(current.hour, 15)
        self.assertEqual(current.minute, 0)

    # withステートメントを使用する場合
    def test_with_freeze(self):
        with freeze_time("2024-12-26 15:00:00", tz_offset=9):
            current = datetime.now(ZoneInfo("Asia/Tokyo"))
            self.assertEqual(current.hour, 15)
```

## おわりに

Webアプリを開発するにあたって、「開発環境ではJST（日本標準時）だが、サーバー環境はUTC（世界協定時）で動いている」といった問題がしばしば発生します。さらに、実装段階では意識していないとタイムゾーンがごちゃごちゃになってしまうとか、取り扱いを間違えると深刻な不具合になるとか、厄介さがあります。

毎回これで悩まされているので、この内容を意識して実装するようにしたいです。