created_at は正しく保存したはずなのに、なぜ画面の時刻が 9 時間ずれるのか?
Rails の画面では正しい時刻が出ているのに、バッチで SQL から入れた行だけ 9 時間先の時刻になる。あるいは、フロントで new Date("2026-10-12") と書いた日付が、海外のユーザーの画面では前日になる。どれも「タイムゾーンの設定ミス」で片付けられがちだが、原因は 1 つの設定ではない。ずれは「時刻」という言葉に 2 種類の意味が混ざっているところから生まれる。
- 時刻には、世界で 1 つに決まる「瞬間」と、場所ごとに読み方が違う「壁時計時刻」の 2 種類があり、両者をつなぐのがタイムゾーンの規則だ。
- PostgreSQL の
timestamptzはタイムゾーンを保存せず瞬間を UTC で持つ型で、timestampは渡されたオフセットを黙って捨てる壁時計時刻の型だ。 - 9 時間ずれは、壁時計時刻を書いた側と読んだ側が別のタイムゾーンを仮定したときに起きる。境界では常にオフセット付きで受け渡すのが対策になる。
「時刻」には 2 種類ある
1 つ目は瞬間(instant)だ。「このリクエストが届いた時点」のように、世界のどこから見ても同じ 1 点を指す。コンピュータは瞬間を Unix 時間(1970-01-01 00:00:00 UTC からの経過秒数)のような 1 本の数直線上の数値で表す。1791795600 という数値は、東京でもニューヨークでも同じ瞬間を指す。
2 つ目は壁時計時刻(wall clock time)だ。「10 月 12 日 18:00」のように、ある場所の時計の針が示す値で、どの場所の時計かを添えないと瞬間が 1 つに決まらない。UTC(協定世界時)は、この壁時計の基準になる世界共通の時計だと考えてよい。
瞬間と壁時計時刻を相互変換するのがタイムゾーンだ。ここで紛らわしいのが、+09:00 のようなオフセット(UTC との差)と、Asia/Tokyo のようなタイムゾーン名は別物だという点だ。オフセットはある瞬間の差を 1 つ書いた数値にすぎない。タイムゾーン名は「いつからいつまでは何時間差か」という規則の履歴全体を指す。America/New_York は夏時間の切り替えで -05:00 と -04:00 を行き来する。
瞬間: Unix 時間 1791795600
(= 2026-10-12 09:00 UTC)
│
├─▶ Asia/Tokyo(常に +09:00)
│ └─▶ 10/12 18:00
│
└─▶ America/New_York
(夏時間中なので -04:00)
└─▶ 10/12 05:00
▲ 規則(タイムゾーン)を通すと
同じ瞬間が別の壁時計時刻になる
この規則は各国の政府が決め、ときに急に変わる。そのため規則は IANA tz データベースにまとめられ、IANA の説明によれば数か月に 1 回程度の頻度で更新版が出ている。OS や言語ランタイムはこのデータを同梱しているので、規則の変更は OS やランタイムを更新しないと反映されない。
timestamptz はタイムゾーンを保存しない
PostgreSQL には timestamp(正式名 timestamp without time zone)と timestamptz(timestamp with time zone)の 2 つの型がある。名前からは「後者はタイムゾーンも一緒に保存する」ように思える。しかし実際は、どちらも 8 バイトで、どちらにもタイムゾーンは入っていない。
timestamptz は「瞬間」の型だ。入力にオフセットがあればそれを使って UTC に変換し、無ければセッションの TimeZone 設定を仮定して変換する。保存されるのは UTC の値だけで、元のオフセットは捨てられる。出力時は毎回、そのセッションの TimeZone に変換して表示する。
timestamp は「壁時計時刻」の型だ。公式ドキュメントによれば、入力文字列にオフセットが書かれていても黙って無視し、日付と時刻の数字だけをそのまま保存する。どの場所の時計の値かという情報は、型にもデータにも残らない。
SET TimeZone = 'Asia/Tokyo'; SELECT '2026-10-12 09:00:00+00'::timestamptz; -- 2026-10-12 18:00:00+09(UTC から変換して表示) SELECT '2026-10-12 09:00:00+00'::timestamp; -- 2026-10-12 09:00:00(+00 は捨てられる)
なお SQL 標準では、型名を timestamp とだけ書くと without time zone の意味になり、PostgreSQL もそれに従う。何も考えずに timestamp と書くと、壁時計時刻の型を選んだことになる。
timestamptz は、受け取った待ち合わせ時刻をすべて「ロンドンの時計で何時か」に換算してメモし、読み上げるときは聞き手の街の時刻に直して伝える秘書だ。timestamp は「15:00」とだけ書いた付箋で、どの街の 15 時かは書いた人の頭の中にしかない。付箋を別の街の人が読めば、自分の街の 15 時だと受け取る。
Rails で 9 時間ずれる典型パターン
Rails は 3 つの層で時刻を扱っている。アプリ側の表示用タイムゾーン config.time_zone、DB に書くときの壁時計の基準 config.active_record.default_timezone(デフォルトは :utc)、そして DB の列の型だ。Rails ガイドによれば、PostgreSQL では t.datetime が作る列はデフォルトで timestamp without time zone になる。
つまり Rails は「UTC の壁時計時刻」を付箋に書き、読むときも「これは UTC だ」と仮定して config.time_zone(たとえば "Tokyo")に変換して表示する。Rails だけが読み書きしている間は、書く側と読む側の仮定が一致するのでずれない。
ずれるのは、別の書き手が別の仮定で同じ列に書いたときだ。たとえばセッションの TimeZone が Asia/Tokyo の psql やバッチから INSERT ... VALUES (now()) を実行すると、now() が返す瞬間は timestamp 列に入る時点で東京の壁時計時刻に変換される。その値を Rails は UTC だと思って読み、さらに +9 時間して表示する。データは壊れていないように見えて、解釈の約束だけが食い違っている。
列を timestamptz にすると、DB 自身が瞬間として保持するので、書き手ごとのセッション設定の違いは変換時に吸収される。現在の Rails では次の設定で t.datetime の既定の型を切り替えられる。既存の列は自動では変わらないため、既存テーブルは別途マイグレーションで型を変える必要がある。
# config/application.rb ActiveSupport.on_load(:active_record_postgresqladapter) do self.datetime_type = :timestamptz end
JavaScript の Date と API の境界
JavaScript の Date の中身は、エポックからのミリ秒数という瞬間だけだ。問題は文字列から作るときに起きる。MDN によれば、"2026-10-12" のように日付だけの文字列は UTC として、"2026-10-12T00:00" のようにオフセットの無い日時の文字列は実行環境のローカル時刻として解釈される。
そのため、日付だけの文字列を UTC の 0 時として読んだあと画面にローカル時刻で表示すると、UTC より西のタイムゾーンでは前日の夕方になる。同じ API レスポンスが、東京の開発者の画面では正しく、カリフォルニアのユーザーの画面では 1 日ずれる。
境界での受け渡しは 2026-10-12T09:00:00Z や 2026-10-12T18:00:00+09:00 のように、オフセットを必ず含む ISO 8601 形式にそろえるのが基本になる。オフセットが付いていれば、受け手がどのタイムゾーンを仮定していても同じ瞬間に復元できるからだ。
「すでに起きた出来事」(注文日時・ログイン日時・ログ)は瞬間として UTC で持てばよく、PostgreSQL なら timestamptz が素直な選択だ。一方「来年 3 月の毎週月曜 10:00 に東京で会議」のような未来の予定は、壁時計時刻と Asia/Tokyo のようなタイムゾーン名をセットで保存する。今の規則で UTC に換算して保存すると、夏時間の導入や廃止で規則が変わったとき、予定が 1 時間ずれた瞬間を指してしまう。誕生日や締め日のように時刻を持たない値は、date 型で日付として持つ方が、0 時の UTC 換算による 1 日ずれを避けられる。
同じ Unix 時間 0 がタイムゾーンによって違う壁時計時刻に見えること、そして JavaScript の文字列解釈の違いを Mac のターミナルで確かめる(node が入っている前提)。
TZ=UTC date -r 0
TZ=Asia/Tokyo date -r 0
TZ=America/Los_Angeles node -e 'console.log(new Date("2026-10-12").toString())'
TZ=America/Los_Angeles node -e 'console.log(new Date("2026-10-12T00:00").toString())'
date -r 0 は 1970 年 1 月 1 日の 00:00 UTC と 09:00 JST を表示し、同じ瞬間が別の壁時計時刻で読まれることが分かる。node の 1 行目は Sun Oct 11 2026 17:00:00 GMT-0700 と前日になり、2 行目は Mon Oct 12 2026 00:00:00 GMT-0700 になるはずだ。違いは文字列に時刻部分があるかどうかだけだ。
timestamp with time zoneは入力時のタイムゾーンを保存している — 保存されるのは UTC に換算した瞬間だけで、元のオフセットは捨てられる。表示時のオフセットは読み手のセッション設定から付け直されたものだ。+09:00とAsia/Tokyoは同じ意味だ — オフセットはある瞬間の差を 1 つ書いた値で、タイムゾーン名は規則の履歴全体を指す。夏時間のある地域では、同じタイムゾーン名でも日付によってオフセットが変わる。- 全部 UTC で保存しておけば時刻の問題は起きない — 過去の出来事には有効だが、未来の現地時刻の予定は、規則の変更で UTC 換算がずれうる。壁時計時刻とタイムゾーン名で持つ方が正しい場合がある。
- Unix 時間
- 1970-01-01 00:00:00 UTC からの経過秒数で瞬間を表す方式。
- UTC
- 協定世界時。各地の壁時計時刻の基準になる世界共通の時刻。
- 壁時計時刻
- ある場所の時計が示す日付と時刻。場所が分からないと瞬間に戻せない。
- オフセット
+09:00のような、ある瞬間における UTC との時差。- IANA tz データベース
Asia/Tokyoなどのタイムゾーン名ごとに時差と夏時間の規則の履歴を収めたデータ。- timestamptz
- PostgreSQL で瞬間を UTC で保持し、表示時にセッションのタイムゾーンへ変換する型。
- PostgreSQL: Date/Time Types —
timestampとtimestamptzの入力・保存・出力の規則が一次情報で確認できる。 - Rails Guides: Configuring — datetime_type — Rails が作る列の型を
timestamptzに切り替える公式の設定方法。 - MDN: Date.parse() — 日付だけの文字列とオフセット無しの日時文字列で解釈が分かれる例が載っている。
- IANA: Time zone and daylight saving time data — tz データベースの命名規則と、規則が政府の決定で変わり続ける事情の説明。