綴Photo の勉強ノートに戻る
技術ブログ2026.07.28

深夜に綴った言葉が前の日として表示される — USE_TZ と isoformat() のずれ

DjangoDRFタイムゾーンAPI綴Photo

写真に重ねて綴った言葉へ「◯月◯日 に追記」と日付を添える機能を作っていて、レスポンスに日付を足した段階で1日ずれているのに気づいた時の記録です。

追記の日付を出すために、レスポンスに書いた日時を足した。

python
return {
    "text": meaning.text,
    # 綴った日時(created_at)を文字列にしてフロントへ渡す
    "writtenAt": meaning.created_at.isoformat() if meaning.created_at else None,
}

これで正しく動いているように見えた。JST の朝から夕方に書いたものは、確かに正しい日付が出ます。

問題は深夜だった。

何が起きるか

日本時間 7月21日の午前1時30分に綴ったとします。9時間戻すと、UTC ではこれは 7月20日の16時30分。時刻を戻したせいで、日付が前の日にまたいでいます。

この9時間だけ、JST と UTC で日付が違うJST(書いた人)7/207/21UTC(DB とモデル)7/207/21JST 0:00JST 9:00午前1時30分に綴った

USE_TZ = True の Django は、データベースに UTC で保存し、モデルから読み出した datetime も UTC のまま返します。settings.TIME_ZONE = "Asia/Tokyo" を設定していても、それは自動では効きません。

python
>>> meaning.created_at.isoformat()
'2026-07-20T16:30:00+00:00'   # 7月20日

日付の部分だけを取って表示すると「7月20日 に追記」になります。本人が書いたのは 7月21日の未明なのに、前の日として出ます。

気づきにくいのは、片方だけ正しく見えているから

厄介だったのは、同じプロジェクトの中に正しく返している別の窓口(エンドポイント)が既にあったことだった。

ずれているのは分かったが、なぜ片方だけ正しいのかはすぐには見当がつかなかった。実装は AI と進めているので、日時を返している箇所を洗い出してもらって並べた。返し方が2通りあった。

DRF(Django REST Framework)の DateTimeField は、シリアライズするときに settings.TIME_ZONE でローカル時刻に直してくれます。だから一覧を返す API はちゃんと +09:00 で返っていた。

返している場所 実装 7/21 01:30 JST の綴りが
一覧の API DRF の DateTimeField 2026-07-21T01:30:00+09:00
作品ページの解決 生の isoformat() 2026-07-20T16:30:00+00:00

どちらのレスポンスを見ているかで日付が変わります。片方だけ見ている限り、バグに見えません。

直し方

表示のために取り出すときに、明示的にローカル時刻へ直します。

python
from django.utils import timezone
 
return {
    "text": meaning.text,
    # settings.TIME_ZONE(Asia/Tokyo)のローカル時刻へ。
    # 素の isoformat() だと UTC のままで、JST 00:00〜09:00 に綴った分が1日前になる。
    "writtenAt": timezone.localtime(meaning.created_at).isoformat() if meaning.created_at else None,
}

timezone.localtime() は settings.TIME_ZONE を見て変換してくれます。DRF が内部でやっているのと同じことを、手書きのレスポンスでも通す形になります。

これで両方の経路が +09:00 で揃った。

学び

  • 「日付」は datetime だけでは決まりません。datetime + タイムゾーンで初めて決まります。 UTC のままの値から日付部分を切り出せば、出てくるのは「UTC での日付」であって、書いた人にとっての日付ではありません。切り出す前に、どのタイムゾーンで読むかを必ず決めます。
  • フレームワークが自動でやってくれる場所と、自分で書く場所が混ざると、同じデータが2つの顔を持ちます。 レスポンスを手で組み立てる関数を書いたら、同じデータを返している他の口と形を突き合わせます。
  • 時差のバグは「ずれる時間帯」でしか出ません。 日中しか触っていないと一生気づかないので、テストを書くならわざとその時間帯のデータを入れます。
python
def test_entries_written_at_is_local_time_not_utc(self):
    # UTC 7/20 16:30 = JST 7/21 01:30。ここがずれる時間帯。
    ...
    written = out["photos"][str(a.id)]["entries"][0]["writtenAt"]
    # 返ってきた文字列が JST の日付で始まり、+09:00 で終わる
    self.assertTrue(written.startswith("2026-07-21T01:30"), written)
    self.assertTrue(written.endswith("+09:00"), written)  # UTC のままなら +00:00

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。