Safie Engineers' Blog!

Safieのエンジニアが書くブログです

タイムゾーンと、Pythonでのその扱い方の注意点

サーバーサイドエンジニアの三村です。

弊社では2024年の初めから国外へサービス展開をする準備として、一年ほど前からシステムの国際化対応を行ってきました。 この準備には、サービスの多言語対応や日本標準時以外のタイムゾーンでサービスが利用できるようにする改修などが含まれますが、サーバーチームでは特に後者に苦労しました。

そこでこの改修で得たPythonでタイムゾーンを扱う際の知見の一部を、このブログ記事にまとめます。

タイムゾーンについて

Pythonでの実装の話に入る前に、まずはタイムゾーンの概念自体について説明します。

タイムゾーンの基準

世界中の各地域は異なる標準時間を用いていて、地域間に時差があることは言うまでもないと思います。 そしてこれらの異なる時間帯の基準となる時刻が存在することも有名かと思います。

こちらの基準時刻は、イギリスのグリニッジ天文台における時間を基に定められていると、昔学校で習った方も多いと思います。 経度0度の地点にある同天文台を基準時間とする考えは、GMT (Greenwich Mean Time)と呼ばれるものであり、現在こちらはUTC (Coordinated Universal Time)という概念に取って代わられています。

慣習的に同一のものと扱われることもある両者ですが、厳密には概念的、算出方法的な違いがあります。

GMTはグリニッジ天文台での地方平均時を表します。つまりGMTは特定地域での天体観測を基に算出される、ロンドンあたりを指す一つのタイムゾーンです。

比べてUTCは、天体観測だけではなく原子時計に基づく計算などから算出される基準時で、これ自体はどこかの地域のタイムゾーンではないです。地球上の地域の標準時を、この基準時との差分で表現するために存在しています。

UTCオフセットだけでタイムゾーンを扱う問題点

UTCのような基準となる時間の概念が存在すれば、それとのオフセット(基準との差異)のみを用いてシステムで世界中の時間を取り扱えそうだと一見思えます。しかし、実際はそんな甘くはないです。以下に、特定地域の人間の用いる時間の概念が単一のUTCオフセットでは表せないことの例を挙げます。

サマータイム

一部のタイムゾーンでは、一年を通して同じUTCオフセットの時間を用いているわけではなく、日照時間が長い時期にはサマータイムとして時間を早めることが行われています。

加えて、サマータイムの開始・終了時期も毎年一定とは限らず、頻繁に変更される地域もあります。 (例:1998年のブラジルでは、サマータイムの開始日をローマ教皇の来訪時期とずらすため一日遅らせました。

特定タイムゾーンでのUTCオフセット変更

歴史を遡れば、地球上の多くの地点で採用している標準時間の変更が行われてきました。 (例:サモアでは、2011年にタイムゾーンをUTC-11からUTC+13に変更しました。

後述する通り、このような標準時間の変更を経験した地域には、日本も含まれます。

IANAのTime Zone Database

上述の通り、人々が生活で用いている時間の概念は、季節的や歴史的な理由から単純なUTCオフセット一つでは表現しきれない場合があります。 そこで、このような一般的に使われるタイムゾーンを、季節的な変化や歴史的な経緯も包括してまとめているのが、Time Zone Databaseです。

www.iana.org

Asia/Tokyoなどのよく見かけるタイムゾーンの表記は、IANAによって編纂されているこちらのデータベースによって規定されています。

こちらのデータベースの情報は、プログラムからはいろいろな環境で利用できます。Unix likeなシステムであれば/usr/share/zoneinfo/にバイナリ形式で入っているほか、Pythonの実行環境から参照したい場合はtzdataというモジュールを用いることでOSによらず利用できます。

こちらの中身を確認する一番簡単な方法は、上記のIANAのリンクを開き、データベースのファイルをダウンロードし解凍し開くことです。試しにニューヨークのタイムゾーン情報を開いてみると、以下のようになっています。

# Monthly Notices of the Royal Astronomical Society 44, 4 (1884-02-08), 208
# says that New York City Hall time was 3 minutes 58.4 seconds fast of
# Eastern time (i.e., -4:56:01.6) just before the 1883 switch.

# Rule NAME    FROM    TO  -   IN  ON  AT  SAVE    LETTER
Rule    NYC 1920   only    -   Mar lastSun 2:00  1:00  D
Rule    NYC 1920   only    -   Oct lastSun 2:00  0  S
Rule    NYC 1921   1966   -   Apr lastSun 2:00  1:00  D
Rule    NYC 1921   1954   -   Sep lastSun 2:00  0  S
Rule    NYC 1955   1966   -   Oct lastSun 2:00  0  S
# Zone NAME        STDOFF  RULES   FORMAT  [UNTIL]
        #STDOFF    -4:56:01.6
Zone America/New_York   -4:56:02 -   LMT 1883 Nov 18 17:00u
            -5:00 US  E%sT    1920
            -5:00 NYC E%sT    1942
            -5:00 US  E%sT    1946
            -5:00 NYC E%sT    1967
            -5:00 US  E%sT

最初にコメントで、1883年の標準時間の変更までは東海岸時間より3分58.4秒早かったとする資料の紹介がされています。 その後過去のサマータイムの実施の歴史や、コメントで書かれていた1883年以前の時間の現在との時差についてが記述されています。

(余談) Time Zone Databaseから読み取る世界の歴史

上記のニューヨークの例の通り、Time Zone Databaseは単なる標準時の変化等を考慮した時刻変換のルールが記載されているのみでなく、豊富なコメントもあり読み物としても面白いです。日本の例を見てみます。

# From Paul Eggert (1995-03-06):
# Today's _Asahi Evening News_ (page 4) reports that Japan had
# daylight saving between 1948 and 1951, but "the system was discontinued
# because the public believed it would lead to longer working hours."

# From Mayumi Negishi in the 2005-08-10 Japan Times:
# http://www.japantimes.co.jp/cgi-bin/getarticle.pl5?nn20050810f2.htm
# Occupation authorities imposed daylight-saving time on Japan on
# [1948-05-01]....  But lack of prior debate and the execution of
# daylight-saving time just three days after the bill was passed generated
# deep hatred of the concept....  The Diet unceremoniously passed a bill to
# dump the unpopular system in October 1951, less than a month after the San
# Francisco Peace Treaty was signed.  (A government poll in 1951 showed 53%
# of the Japanese wanted to scrap daylight-saving time, as opposed to 30% who
# wanted to keep it.)

# -- 中略 --

# Rule NAME    FROM    TO  -   IN  ON  AT  SAVE    LETTER/S
Rule    Japan   1948   only    -   May Sat>=1  24:00 1:00  D
Rule    Japan   1948   1951   -   Sep Sat>=8  25:00 0  S
Rule    Japan   1949   only    -   Apr Sat>=1  24:00 1:00  D
Rule    Japan   1950   1951   -   May Sat>=1  24:00 1:00  D

# Zone NAME        STDOFF  RULES   FORMAT  [UNTIL]
Zone    Asia/Tokyo  9:18:59  -   LMT 1887 Dec 31 15:00u
            9:00  Japan   J%sT

コメント及びRuleの項には、日本が過去に実施していたサマータイムの情報が載っています。終戦直後日本ではGHQの指導によりタイムゾーンを導入していたようですが、人々はサマータイムによって労働時間を伸ばされるのではないかと不安になり大変不人気であったため、サンフランシスコ平和条約締結後日本が主権を回復すると、すぐにサマータイムは廃止されたそうです。

ちなみにZoneの項目にある通り、日本は1888年の正月より前には今より18分59秒早い標準時間を用いていました(東京の地方平均時)。 今までプログラムで日本時間を扱うコードを書いた際に、タイムゾーン取り扱いの不備で意図した時刻よりも18分59秒ずれた時刻となるバグを起こすなどしてこの時間を見たこともあるかと思いますが、この時間はここからきています(詳しい例は後述します)。

Pythonでの実装の注意点

やっとPythonの話に入ります。 Pythonでtimezone awareな形でdatetimeオブジェクトを取り扱う際に陥りそうな誤りを、いくつか挙げてみます。

pytzをdatetimeオブジェクトのコンストラクタに渡す

Pythonにおけるタイムゾーン関連のよくある間違いでは一番有名な話かもしれませんが、pytz型のタイムゾーンをdatetimeオブジェクトのコンストラクタに渡すと、大抵の場合意図しないdatetimeオブジェクトが作成されます。

>>> import datetime
>>> import pytz
>>> datetime.datetime(2023,8,25,12,0,0, tzinfo=pytz.timezone('Asia/Tokyo')).utcoffset()
datetime.timedelta(seconds=33540)
# ↑9時間19分

上記のようなコードを書いた場合、実装者は大抵の場合UTCオフセットが9時間ちょうどのタイムゾーンのオブジェクトの生成を期待していると思いますが、実際には9時間19分となっています。

pytzはdatetimeオブジェクトのコンストラクタに渡されるような利用方法は想定されておらず(詳しくは後述)、このような利用方法ができないことがドキュメントに記載されています。

This library differs from the documented Python API for tzinfo implementations; if you want to create local wallclock times you need to use the localize() method documented in this document.

上記引用にある通り、pytzでdatetimeオブジェクトにタイムゾーン情報を付与したい場合は、localize()を用いるのが正解です。

>>> pytz.timezone('Asia/Tokyo').localize(datetime.datetime(2023,8,25,12,0,0)).utcoffset()
datetime.timedelta(seconds=32400)
# ↑9時間ちょうど

ちなみに、pytzをdatetimeオブジェクトのコンストラクタに渡す際のバグは上に例示しましたが、これはコンストラクタだけでなくreplace()の引数として渡しても同様の事象は起こります。

>>> import datetime
>>> import pytz
# まずはtimezone unawareなdatetimeオブジェクトを作成
>>> d = datetime.datetime(2023,8,25,12,0,0)
>>> d
datetime.datetime(2023, 8, 25, 12, 0)
# これにreplace()でtimezone情報をつけると、やはり19分ずれる
>>> d.replace(tzinfo=pytz.timezone('Asia/Tokyo')).utcoffset()
datetime.timedelta(seconds=33540)
# ↑9時間19分

こちらの理由は単純で、datetimeクラスのreplace()は内部で新規にオブジェクトの生成を行っていて、その際にコンストラクタの引数にreplace()の引数をそのまま渡しているためです。

github.com

def replace(self, year=None, month=None, day=None, hour=None,
            minute=None, second=None, microsecond=None, tzinfo=True,
            *, fold=None):
    """Return a new datetime with new values for the specified fields."""
    if year is None:
        year = self.year
    if month is None:
        month = self.month
    if day is None:
        day = self.day
    if hour is None:
        hour = self.hour
    if minute is None:
        minute = self.minute
    if second is None:
        second = self.second
    if microsecond is None:
        microsecond = self.microsecond
    if tzinfo is True:
        tzinfo = self.tzinfo
    if fold is None:
        fold = self.fold
    return type(self)(year, month, day, hour, minute, second,
                        microsecond, tzinfo, fold=fold)

最終行で、新たにオブジェクトを作成しそのコンストラクタの中でtzinfoも渡しています。

pytzオブジェクトをdatetimeのコンストラクタに渡してもうまく動かない理由

pytzのこの挙動はPython界隈では結構有名ですが、なぜこのような挙動になっているのか説明しているブログ記事等は、(自分のなけなしのリサーチ能力だと)見当たりませんでした。そこで、少し自分で実装を調べてみました。

まず、Pythonのdatetimeのドキュメントには以下のような記述がありました。

docs.python.org

For applications requiring aware objects, datetime and time objects have an optional time zone information attribute, tzinfo, that can be set to an instance of a subclass of the abstract tzinfo class. These tzinfo objects capture information about the offset from UTC time, the time zone name, and whether daylight saving time is in effect.

Only one concrete tzinfo class, the timezone class, is supplied by the datetime module. The timezone class can represent simple timezones with fixed offsets from UTC, such as UTC itself or North American EST and EDT timezones. Supporting timezones at deeper levels of detail is up to the application. The rules for time adjustment across the world are more political than rational, change frequently, and there is no standard suitable for every application aside from UTC.

Pythonの標準ライブラリであるdatetimeでは、タイムゾーン情報としてはtzinfoという抽象クラス*1と、その実装としてtimezoneというクラスを用意していますが、こちらの実装はUTCオフセットが固定である前提となっています。UTCオフセットが歴史的経緯などで変わってくるような、実際の時間の概念に近いものが使いたければ、同ライブラリを利用するアプリケーション側でtzinfoをよしなに実装してどうにかしてね、という意図のようです。

こちらのtzinfoのクラスですが、実装の際にUTCオフセットをいい感じに対応するにはutcoffset()というメソッドを上書きしてほしいそうです。

class tzinfo:
    """Abstract base class for time zone info classes.

    Subclasses must override the tzname(), utcoffset() and dst() methods.
    """
    __slots__ = ()

    def tzname(self, dt):
        "datetime -> string name of time zone."
        raise NotImplementedError("tzinfo subclass must override tzname()")

    def utcoffset(self, dt):
        "datetime -> timedelta, positive for east of UTC, negative for west of UTC"
        raise NotImplementedError("tzinfo subclass must override utcoffset()")

    def dst(self, dt):
        """datetime -> DST offset as timedelta, positive for east of UTC.

        Return 0 if DST not in effect.  utcoffset() must include the DST
        offset.
        """
        raise NotImplementedError("tzinfo subclass must override dst()")

# 以下略

次に、datetimeオブジェクトのコンストラクタに渡しても正しく動く、dateutil.tzの実装ではこのutcoffset()をどう実装しているのかみてみます。

github.com

def utcoffset(self, dt):
    if dt is None:
        return None

    if not self._ttinfo_std:
        return ZERO

    return self._find_ttinfo(dt).delta

# 中略 ↓こちらは上の関数から呼ばれている

def _find_ttinfo(self, dt):
    idx = self._resolve_ambiguous_time(dt)

    return self._get_ttinfo(idx)

# 中略 ↓こちらは上の関数から呼ばれている

def _resolve_ambiguous_time(self, dt):
    idx = self._find_last_transition(dt)

    # If we have no transitions, return the index
    _fold = self._fold(dt)
    if idx is None or idx == 0:
        return idx

    # If it's ambiguous and we're in a fold, shift to a different index.
    idx_offset = int(not _fold and self.is_ambiguous(dt, idx))

    return idx - idx_offset

上のコードでは、utcoffset()が呼ばれた際には毎回、内部で標準時間の変遷の歴史を参照する関数を呼び出し、その結果を参考に採用すべきUTCとのオフセットを導き出しています。このことから、dateutil.tzではutcoffset()が呼び出されると、その都度標準時間の遍歴を考慮してUTCオフセットの判定をしていることがわかります。

比べて、コンストラクタに渡すとうまく動かないpytzの方の実装を見てみます。

github.com

def utcoffset(self, dt, is_dst=None):
# 関数のコメントは長いので略
    if dt is None:
        return None
    elif dt.tzinfo is not self:
        dt = self.localize(dt, is_dst)
        return dt.tzinfo._utcoffset
    else:
        return self._utcoffset

こちらは条件分岐によっては、dateutil.tzでの場合のようなUTCオフセットの決定のための複雑な計算はせずに、事前にクラスにセットされた_utcoffsetの値を単純に返すのみになります。基本的にdatetimeオブジェクトが生成されてからはutcoffset()が呼ばれる際には、上記コードの条件分岐一番最後のelse節に入るようであったので、pytzはインスタンス作成時に設定されたUTCオフセットを返し続ける挙動になっていそうです。

上記の実装2例からは、dateutil.tzの方はUTCオフセットを参照された場合は毎回引数のdatetimeオブジェクトの日付を確認してUTCオフセットを決定していて、pytzの方は一度UTCオフセットの値が設定されたらそれを返し続ける、という違いが見て取れます。

datetimeオブジェクトのコンストラクタにタイムゾーンを渡すユースケースの場合、先に引数として渡すために作成されるタイムゾーンオブジェクトは作成予定のdatetimeオブジェクトを読み取れないので、どのようなtzinfoの実装であれ一旦は正確にはUTCオフセットを決められない状態になります。dateutil.tzの方はその後改めてdatetimeオブジェクトの日付を確認してUTCオフセットを再計算するタイミングがあるのに対し、pytzの場合はそれがなさそうです。

このことから、pytzオブジェクトをdatetimeオブジェクトのコンストラクタに渡すと、UTCオフセットが不明な状態で記録された該当タイムゾーンでの最も古いUTCオフセットの情報を保持し続け*2、上述のような挙動が起こると思われます。

zoneinfoファイルの「1901年問題」

上で、Time Zone Databseの情報はUnix likeな環境では/usr/share/zoneinfo以下に同情報が入っていると書きました。 このファイルを用いてタイムゾーンを扱う際の意図しない挙動を紹介します。

一部の環境*3で以下のようなコードを実行すると、意図した時間よりも18分59秒ずれることになります。

>>> import datetime
>>> import dateutil.tz

# 年月日はなしに時間のみを指定してdatetimeオブジェクトを作成し、それにdateutil.tzでAsia/Tokyoのタイムゾーン情報を付与し、UTCオフセットを取得
>> datetime.datetime.strptime('12:00', '%H:%M').replace(tzinfo=dateutil.tz.gettz('Asia/Tokyo')).utcoffset()
datetime.timedelta(seconds=33539)
# UTCオフセットは9時間(32400秒)を期待しているのに、9時間18分59秒(33539秒)となった

(上のコードはそもそも、日付のない時間のデータを扱うのにdatetime.timeではなく余計に情報量の多いdatetime.datetimeを用いているところが本質的な間違いですが、そこには目を瞑ってください。)

こちらは、datetimeオブジェクトで時間のみを指定すると年月日は1900年1月1日となること、一部の環境でTime Zone Databaseの情報が載っているバイナリのファイルで日付データを32ビットまでしか扱えていないこと、の二つの要因が重なってこのような挙動になっています。

前者については、1900年1月1日時点では既に日本では今と同じ標準時間が採用されている状態であったため、こちら単体ではUTCオフセットは9時間18分59秒とはならないはずです。

後者については、32ビットで扱える時間の範囲を超えるとコンピュータが誤動作すると言われる「2038年問題」と同源です。32bitの秒数でエポック時間から負の方向に向かって表現できる限界は1901年の12月13日であり、datetimeオブジェクトに自動でつけられた1900年1月1日はこれよりも古いため、一部環境でUTCオフセットの計算がうまくいかなっているようです。

上記の事象が再現する環境で参照されているタイムゾーンの情報のファイルを、RFC8536での仕様をもとにバイナリを読んでみます。

$ hexdump -C /usr/share/zoneinfo/Asia/Tokyo
00000000  54 5a 69 66 32 00 00 00  00 00 00 00 00 00 00 00  |TZif2...........|
00000010  00 00 00 00 00 00 00 04  00 00 00 04 00 00 00 00  |................|
00000020  00 00 00 09 00 00 00 04  00 00 00 0c 80 00 00 00  |................|
00000030  d7 3e 02 70 d7 ed 59 f0  d8 f8 fa 70 d9 cd 3b f0  |.>.p..Y....p..;.|
00000040  db 07 00 f0 db ad 1d f0  dc e6 e2 f0 dd 8c ff f0  |................|
00000050  03 01 02 01 02 01 02 01  02 00 00 83 03 00 00 00  |................|
00000060  00 8c a0 01 04 00 00 7e  90 00 08 00 00 7e 90 00  |.......~.....~..|
00000070  08 4c 4d 54 00 4a 44 54  00 4a 53 54 00 00 00 00  |.LMT.JDT.JST....|
00000080  01 00 00 00 01 54 5a 69  66 32 00 00 00 00 00 00  |.....TZif2......|
00000090  00 00 00 00 00 00 00 00  00 00 00 00 04 00 00 00  |................|
000000a0  04 00 00 00 00 00 00 00  0a 00 00 00 04 00 00 00  |................|
000000b0  0c f8 00 00 00 00 00 00  00 ff ff ff ff 65 c2 a4  |.............e..|
000000c0  70 ff ff ff ff d7 3e 02  70 ff ff ff ff d7 ed 59  |p.....>.p......Y|
000000d0  f0 ff ff ff ff d8 f8 fa  70 ff ff ff ff d9 cd 3b  |........p......;|
000000e0  f0 ff ff ff ff db 07 00  f0 ff ff ff ff db ad 1d  |................|
000000f0  f0 ff ff ff ff dc e6 e2  f0 ff ff ff ff dd 8c ff  |................|
00000100  f0 00 03 01 02 01 02 01  02 01 02 00 00 83 03 00  |................|
00000110  00 00 00 8c a0 01 04 00  00 7e 90 00 08 00 00 7e  |.........~.....~|
00000120  90 00 08 4c 4d 54 00 4a  44 54 00 4a 53 54 00 00  |...LMT.JDT.JST..|
00000130  00 00 01 00 00 00 01 0a  4a 53 54 2d 39 0a        |........JST-9.|
0000013e

下記画像に説明を書いた通り、本ファイルにはUTCオフセットが今よりも18分59秒ずれていた時期のルールが記載されていますが、そちらが採用されていた最後の時間にあたる部分に、符号付き32bitのintergerの最小値である80 00 00 00(-2147483648)が入っています。こちらをUNIX timeとして読み取ると1901年12月13日中の時間になるため、それ以前の時間はこのファイルを参照する限りUTCオフセットが9時間18分59秒と扱われてしまいます。(実際にこのUTCオフセットの標準時間が利用されていたのは1887年末までなので、およそ14年分の日本時間は正しく変換できないことになります。)

逆に、この現象が起きない筆者のMac上の同じファイルを見てみると、この符号付き32bit整数ではUNIX timeとして扱えない時期のデータは、省略されていることがわかりました。

% hexdump -C /usr/share/zoneinfo/Asia/Tokyo
00000000  54 5a 69 66 32 00 00 00  00 00 00 00 00 00 00 00  |TZif2...........|
00000010  00 00 00 00 00 00 00 03  00 00 00 03 00 00 00 00  |................|
00000020  00 00 00 08 00 00 00 03  00 00 00 08 d7 3e 02 70  |.............>.p|
00000030  d7 ed 59 f0 d8 f8 fa 70  d9 cd 3b f0 db 07 00 f0  |..Y....p..;.....|
00000040  db ad 1d f0 dc e6 e2 f0  dd 8c ff f0 00 01 00 01  |................|
00000050  00 01 00 01 00 00 8c a0  01 00 00 00 7e 90 00 04  |............~...|
00000060  00 00 7e 90 00 04 4a 44  54 00 4a 53 54 00 00 00  |..~...JDT.JST...|
00000070  01 00 00 01 54 5a 69 66  32 00 00 00 00 00 00 00  |....TZif2.......|
00000080  00 00 00 00 00 00 00 00  00 00 00 04 00 00 00 04  |................|
00000090  00 00 00 00 00 00 00 09  00 00 00 04 00 00 00 0c  |................|
000000a0  ff ff ff ff 65 c2 a4 70  ff ff ff ff d7 3e 02 70  |....e..p.....>.p|
000000b0  ff ff ff ff d7 ed 59 f0  ff ff ff ff d8 f8 fa 70  |......Y........p|
000000c0  ff ff ff ff d9 cd 3b f0  ff ff ff ff db 07 00 f0  |......;.........|
000000d0  ff ff ff ff db ad 1d f0  ff ff ff ff dc e6 e2 f0  |................|
000000e0  ff ff ff ff dd 8c ff f0  03 01 02 01 02 01 02 01  |................|
000000f0  02 00 00 83 03 00 00 00  00 8c a0 01 04 00 00 7e  |...............~|
00000100  90 00 08 00 00 7e 90 00  08 4c 4d 54 00 4a 44 54  |.....~...LMT.JDT|
00000110  00 4a 53 54 00 00 00 00  01 00 00 00 01 0a 4a 53  |.JST..........JS|
00000120  54 2d 39 0a                                       |T-9.|
00000124

データの個数が問題の起こる環境と比べ一つ減っていることと、9時間18分59秒のUTCオフセットを表す83 03(09:18:59を秒数にした33540の16進数表記)という値が(時間の変換ルールの項目からは)見当たらないことがわかります。

これによって1888年1月1日から1901年12月13日までの日本の日付を正しく扱えるようになりますが、逆に1887年末以前のデータは実際の当時の標準時とはずれた時間で取り扱われます。

# 1887年末以前の標準時がzoneinfoファイルに記録されていない、筆者のMacで実行
>>> datetime.datetime(1887, 1, 1, 0, 0, 0, tzinfo=dateutil.tz.gettz("Asia/Tokyo")).utcoffset()
datetime.timedelta(seconds=32400)
# ↑実際は33540(9時間18分59秒)であるべきが、32400(9時間)となってしまっている

何らかの理由で1887年以前の日本の日付データを扱いたい場合は、こちらの挙動に気をつける必要がありそうです。

strftime(%s)でのエポック秒変換

datetimeオブジェクトをエポック秒に変換したい場合に、strftime(%s)を用いることでこれが実現できると書いてある記事がちらほら存在します。 しかしこちらはPythonのdatetimeのドキュメントではサポートされているとは一切書いておらず、非推奨です。

# 日本時間で動く環境です
>>> import time
>>> time.tzname
('JST', 'JST')

>>> import datetime
>>> import dateutil.tz
# 現在時刻を取得した後、それにニューヨークのタイムゾーンを付与します
>>> d = datetime.datetime.now().astimezone(dateutil.tz.gettz('America/New_York'))
>>> d
datetime.datetime(2024, 2, 8, 1, 41, 57, 844204, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))

# strftime(%s)を用いてエポック秒に変換
>>> epoch_s = d.strftime("%s")
>>> epoch_s
'1707324117'

# これをdatetimeに変換し再びニューヨークのタイムゾーンをつけると、もとより14時間ずれていることがわかります
>>> datetime.datetime.fromtimestamp(int(epoch_s)).astimezone(dateutil.tz.gettz('America/New_York'))
datetime.datetime(2024, 2, 7, 11, 41, 57, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))
# こちらはエポック秒変換の際に、ニューヨークでの時間としてではなく日本時間として変換されたためです

上記のコード例の通り、strftime(%s)だと変換元のdatetimeオブジェクトがtimezone awareであったとしても、変換時にはそのタイムゾーン情報ではなく、実行環境のタイムゾーン情報を参照してしまいます。 (日本時間で動く環境で、日本時間の時刻データのみを扱っていると気づかずにこのような実装が紛れてしまうかもしれませんが、どちらか一方が日本時間以外となるとこちらは不具合を起こします。)

こちらdatetimeオブジェクトをエポック秒に変換したい場合は、timestamp()を使うのが正解です。

まとめ

  • コンピュータで扱う場合のタイムゾーンの概念は、単なる地域間の時差の寄せ集めではなく、歴史的変遷など時間軸の情報も含んだ複雑なものです
  • (Pythonやタイムゾーンとかに限った話ではないですが)ちゃんとドキュメント読みましょう

*1:厳密にいうとこれは抽象クラスとは呼ばないかもしれないですが、実際の実装はなく利用側での関数のoverrideを期待しているクラスという意味で、雑にこの語を使ってます

*2:pytzのオブジェクトがコンストラクタ内でとりあえず最も古い時期のUTCオフセットを参照する部分の実装はこちら: https://github.com/stub42/pytz/blob/fb43f957c5149e750c3be3cfc72b22ad94db4886/src/pytz/tzinfo.py#L189

*3:ubuntu:focal-20230412のDockerイメージをもとにしたコンテナでapt-get install tzdataをした際に発生するのを確認しました

© Safie Inc.