🎉

Pythonのタイムゾーンはpytzよりzoneinfoかdateutils.tzを使おう、という話

2020/09/29に公開

Python3.9では zoneinfoモジュールが追加され、標準ライブラリだけでタイムゾーンを使えるようになりました。

私もまだ使ったことはないのに無責任に勧めてしまいますが、今後の新しいプロジェクトでタイムゾーンを使うときは zoneinfo を使いましょう。しかし、Python3.8以前を利用しているプロジェクトでは、いまだに pytz を使っている場合も多いのではないでしょうか?

pytz以外にも、dateutilsパッケージのdateutil.tz もタイムゾーン機能を提供しており、pytz よりもこちらのほうがおすすめです。

pytzの困ったところその1

Python3.6以降では、夏時間によって発生する曖昧な時刻の問題に対処するため、PEP 495 -- Local Time Disambiguation が導入されました。

夏時間を採用している地域では一日に同じ時間が2回発生する場合があり、PEP496ではこの問題に対処するために、 datetime オブジェクトに1回目と2回めを区別する fold という属性が追加されました。

pytzは、Python3.6以前から存在しており、独自に曖昧な時刻を明確にする機能を持っています。しかし、datetimeオブジェクトのfold属性には対応しておらず、正しくfoldを設定していても無視されてしまいます。

一方、zoneinfodateutil.tzはもちろん fold属性をサポートしており、問題は発生しません。

pytzの困ったところその2

pytzでは、タイムゾーン情報を

tz = pytz.timezone("Asia/Tokyo")

のように取得します。pytzから取得する tz はPythonのdatetimeモジュールのtzinfoクラスのサブクラスであり、一見、Pythonのタイムゾーン情報を表すtzinfoとして使えるように見えます。しかし、前述の夏時間対応のためなどに特殊な機能を持っており、tzinfo と同じようには使えません。pytz独自のインターフェースとして、Pythonのdatetimeモジュールの仕組みとは区別して利用する必要があります。

通常の、zoneinfodateutil.tzで取得したタイムゾーンオブジェクトは

datetime(2020, 1, 1, tzinfo=tz)

のようにdatetime()オブジェクトの構築時に指定したり、

datetime(2020, 1, 1).replace(tzinfo=tz)

のようにreplace() メソッドに指定したりできるのですが、pytzの場合はこういった使い方をすると、つぎのように間違った結果になってしまいます。

>>> tz = pytz.timezone("Asia/Tokyo")
>>> print(datetime(2020, 1, 1).replace(tzinfo=tz))
2020-01-01 00:00:00+09:19

pytzを使う場合は、tz.localize()datetime.astimezone(tz)などのメソッドだけを使うように気をつける必要があります。

pytzの困ったところその3

現在、日本の標準時(JST)は、協定世界時(UTC)と比べて9時間の時差があります。この9時間という時差は昔からずっと9時間だったわけではなく、大昔は9時間19分という半端な時間だったころもありました。

pytzなどが利用するタイムゾーンの情報を記録したデータベースには、こういった標準時の歴史的な変遷も記録されており、対象の日付によって、適切な時差(オフセット)がわかるようになっています。

pytz は独自にタイムゾーンデータベースを持っていますが、それとは別にオペレーティングシステムもタイムゾーンデータベースを持っており、datetimeモジュールなどはこちらを利用します。このデータベースが、pytzのデータベースと、プラットフォームのデータベースで食い違っている場合、正しい結果が得られない場合があります。

pytz を使って、次のように1885年から1904年までの時間を変換してみましょう。

import pytz, datetime
tz = pytz.timezone("Asia/Tokyo")

for i in range(1885, 1905):
    print(datetime.datetime(i,1,1,0,0,0).astimezone(tz))

この実行結果はプラットフォームとpytzのバージョンによって異なりますが、手元のmacOS Catalinaでは次のようになります。

1885-01-01 00:00:01+09:19
1886-01-01 00:00:01+09:19
1887-01-01 00:00:01+09:19
1888-01-01 00:00:01+09:19
1889-01-01 00:19:00+09:19
1890-01-01 00:19:00+09:19
1891-01-01 00:19:00+09:19
1892-01-01 00:19:00+09:19
1893-01-01 00:19:00+09:19
1894-01-01 00:19:00+09:19
1895-01-01 00:19:00+09:19
1896-01-01 00:19:00+09:19
1897-01-01 00:19:00+09:19
1898-01-01 00:19:00+09:19
1899-01-01 00:19:00+09:19
1900-01-01 00:19:00+09:19
1901-01-01 00:19:00+09:19
1902-01-01 00:00:00+09:00
1903-01-01 00:00:00+09:00
1904-01-01 00:00:00+09:00

結果をみると、次のような感じになっています。

  • 1888/1/1までは時刻は00:00:01で、時刻のオフセットは09:19
  • 1889/1/1から1901/1/1までは時刻が00:19のオフセットが09:19
  • それ以降は時刻が00:00:00でオフセットは09:00

なんだか不思議な結果になっています。これは pytz のデータベースとシステムのデータベースで Asia/Tokyo の内容が異なっているためです。macOSのデータベースでは1888年までのオフセットは9:18:59、それ以降は9:00となっているのですが、pytzのデータベースでは1901年までが09:19で、それ以降が9:00となっています。

Pythonのdatatime.astimezone()メソッドは、変換時に元の値をシステムのデータベースから取得したオフセットを使ってUTCに変換してから、その時刻をastimezone()に指定した pytz のタイムゾーン情報で変換します。どちらのデータベースが正しいのかは知りませんが、いずれにしろ、システムのデータベースとpytzのデータベースに食い違いがあると、こんな感じで不思議な時間が出てきてしまうのです。

日本の場合はそれほどではありませんが、一つの国の中でも複数の時差と夏時間が入り交じる欧米諸国や、新興国などでは、もっと混乱した結果になってしまう場合もあるようです。zoneinfodateutil.tzでは、(Windowsのようにデータベースを利用できない場合を除けば)プラットフォームのデータベースだけ使うため、何がほんとうに正しい結果なのかはともかく、すくなくともある程度の一貫性は確保された結果を得られます。

ということで

上記のpytzの問題は、使い方を工夫すればある程度回避は可能です。しかし、現在はzoneinfodateutil.tzなどが使えますので、少なくとも新しいプロジェクトではpytzを使って苦労するより、回避が不要なzoneinfodateutil.tzを使ったほうが良いのではないでしょうか?

Discussion