
日付・タイムゾーンのバグ調査|「1日ズレる」の原因を追う手順
「昨日の売上を集計したら、1日分ズレている」 「月初の件数だけ、どうしても合わない」 「利用者から『登録日が前の日になっている』と問い合わせが来た」
——こういう報告が来たとき、いちばん困るのは、コードを読んでも、どこも間違って見えないことだと思います。
計算式は合っている。テストでも再現しない。自分の画面で試すと正しく出る。なのに、本番の数字だけがズレている。そして「たぶんデータの入り方の問題です」とも言い切れず、報告も止まる。
その手前で立ち止まっているなら、疑う先はひとつ絞れます。日付とタイムゾーンです。ここのバグは、ロジックの誤りではなく「同じ時刻を、どこが何時だと思っているか」の食い違いとして起きます。だから読んでも見えません。見えないだけで、確かめる順番はちゃんとあります。
結論:調査はこの順番でやります。①ズレの「幅」を測る(9時間か・1日か・一部だけか)→ ②生のデータを見る(画面の表示ではなく、DBに入っている値そのもの)→ ③4つの層のタイムゾーン設定を突き合わせる(サーバー・DB・アプリ・画面)→ ④ズレる時間帯で再現する → ⑤直す場所をひとつに決める。いちばんの勘所は、幅を先に測ること。9時間ちょうどなら設定の食い違い、ぴったり1日なら日付の境界、バラバラなら別の原因です。幅が分かった時点で、疑う場所は3分の1に減ります。
なお、タイムゾーンの扱いは言語・DB・フレームワーク・ホスティング環境(自社サーバー/レンタルサーバー/クラウド/コンテナ)でかなり違います。本文は一般的な進め方の一例です。コマンドや設定は本番で実行する前に、検証環境と公式ドキュメントで挙動を確認してください。
何が起きているか:日付は4つの層を通っている

ひとつの「2026年8月22日 2時12分」という時刻は、画面に出るまでに何度も持ち替えられています。
- サーバー(OS):cronやログの時刻を決めている、いちばん下の時計。
- データベース:接続ごとに「いまは何時か」を持っています。ここが
UTCのままの現場は珍しくありません。 - アプリケーション:PHPやPython、Node.jsなどが持つ既定のタイムゾーン。
- 画面(ブラウザ・スマホ):利用者の端末が持っている時計。
厄介なのは、この4つは別々に設定できてしまうことです。サーバーは Asia/Tokyo、DBは UTC、アプリは設定漏れで UTC——という組み合わせが、ごく普通に成立します。しかも、それぞれの層は「自分の時計が正しい」と信じて動くので、どこもエラーを出しません。静かにズレるだけです。
そしてもうひとつ。日本標準時(JST)は世界標準時(UTC)より9時間進んでいます。日本にはサマータイムがないので、この差は年間を通じて9時間で固定です。この「9」という数字が、あとで大きな手がかりになります。
よくあるズレ方を、幅で見分ける
「ズレている」と言われたときに最初にやることは、犯人探しではなく採寸です。何時間、あるいは何日ズレているのか。ここで場所がほぼ決まります。
ちょうど9時間ズレている → タイムゾーン設定の食い違い
もっとも多い型です。どこかの層が UTC のまま動いています。
分かりやすいのは、深夜0時から朝9時までに起きたデータだけがおかしくなる現象です。日本の8月22日 2時は、UTCでは8月21日の17時。日付が変わってしまうので、「深夜に登録した分だけ、前日の集計に入る」という形で表に出ます。
日中に登録した分は日付が変わらないため、昼間にテストしても再現しません。これが「テストでは出ないのに本番で出る」の正体であることが、かなりあります。
ちょうど1日ズレている → 日付の「境界」の扱い
時刻ではなく日付だけがズレている場合は、集計の範囲の切り方を疑います。
- 「8月1日から8月31日まで」を
>= '2026-08-01' AND <= '2026-08-31'と書くと、8月31日の0時0分0秒しか含まれません。31日の日中のデータが丸ごと落ちます。< '2026-09-01'と書けば、この落とし穴は避けられます。 - 「昨日の分」を「今日の0時から24時間引く」で出しているとき、実行時刻が0時をまたぐと、対象日が1日ずれます。
- 日付だけの文字列(
2026-08-22)を日時に変換するとき、0時と解釈されるか、UTCの0時と解釈されるかで、日本時間では前日になります。JavaScriptのnew Date("2026-08-22")はUTCの0時と解釈されるため、日本時間で表示すると8月22日の9時になります。一方new Date("2026/08/22")は端末のローカル時刻の0時です。同じ「8月22日」を書いたつもりで、結果が違います。
月末・月初だけおかしい → 月の加算・減算
「1か月前」「翌月」を計算しているところは、月末が絡むと素直に動きません。
たとえば1月31日に1か月足すと、実装によっては「2月31日」がいったん作られ、それが補正されて3月2日や3月3日になります(2月が何日あるかで変わります)。月次のバッチが月末に走る現場では、年に数回だけ結果がおかしくなるという形で現れます。再現性が低いので、原因不明のまま放置されがちなところです。
半端な時間ズレる・特定の時期だけ → 海外のサマータイム
外部サービスや海外拠点のデータが絡む場合は、サマータイム(DST)を疑います。米国や欧州には、年に2回、時計が1時間動く日があります。
その日には、存在しない時刻(1時間飛ぶ)と、2回ある時刻(同じ時刻が繰り返される)が生まれます。「年に2日だけ、集計が二重になる」「その日のデータだけ登録できない」は、この形です。日本の現場でも、海外SaaSのAPIから受け取る日時では十分起こります。
バラバラにズレる → タイムゾーンではないかもしれない
幅が一定でないなら、いったんタイムゾーンから離れます。バッチの実行時刻とデータの投入時刻が競合している、キャッシュが古い日付を返している、といった別の原因のほうが近いことがあります。この場合はキャッシュが原因の「直したのに直らない」の切り分けのほうが近道です。
調査の手順
① ズレの幅を測る
まず、確実に事実だと言えるものを1件つかまえます。
- 問い合わせの中から、具体的な1件(この注文、このユーザー、この日時)を特定する。
- その1件について、本来こうあるべき時刻と、実際に出ている時刻を紙かメモに並べて書く。
- 差を引き算する。9時間か、1日か、1時間か、それ以外か。
地味ですが、ここを飛ばして設定を眺めはじめると、時間が溶けます。差の数字が出るまでは、まだ調査が始まっていないくらいに思っておくとちょうどいいです。
② 生のデータを見る
次に、画面に出ている値ではなく、DBに入っている値そのものを見ます。画面の表示は加工されたあとなので、どこでズレたのか分かりません。
-- MySQL:接続のタイムゾーンと、いまの時刻の見え方を確認する
SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();
-- 実際のレコードを1件見る(カラム名・テーブル名は自分の環境に置き換える)
SELECT id, created_at FROM orders WHERE id = 12345;
-- PostgreSQL:同じことを確認する
SHOW timezone;
SELECT now(), now() AT TIME ZONE 'UTC';
ここで見たいのは、入っている値が日本時間なのか、UTCなのかです。深夜1時に登録されたはずのデータが 16:00 になっていれば、UTCで保存されています。
もうひとつ、カラムの型も確認しておきます。型によってDBの振る舞いが変わります。
- MySQL …
TIMESTAMP型は内部的にUTCで保持され、接続のタイムゾーンに合わせて変換して返されます。DATETIME型は変換されず、入れた値がそのまま出ます。同じテーブルに両方が混ざっていると、片方だけズレます。 - PostgreSQL …
timestamptz(タイムゾーン付き)は内部でUTCとして扱われ、表示時に変換されます。timestamp(なし)は変換されません。
「保存はUTCで、表示だけ日本時間に変換する」は、正しい設計のひとつです。UTCで入っていること自体はバグではありません。 問題になるのは、変換するはずの層が変換していない、あるいは二重に変換しているときです。
③ 4つの層の設定を突き合わせる
幅が9時間なら、ここで犯人が見つかります。上から順に確認します。
# サーバー(OS)のタイムゾーン
timedatectl # systemd 環境
date +"%Z %z" # どの環境でも使える簡易確認
# cron の実行時刻がどのタイムゾーンで動くか(基本はサーバーのタイムゾーン)
sudo crontab -l
# PHP の既定タイムゾーン
php -i | grep -i "date.timezone"
php -r 'echo date_default_timezone_get(), PHP_EOL;'
# Node.js
node -e 'console.log(process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone)'
# Python
python3 -c "import datetime; print(datetime.datetime.now().astimezone().tzinfo)"
アプリの設定は、フレームワークの設定ファイルが優先されることが多い点に注意してください。php.ini が Asia/Tokyo でも、フレームワーク側の設定で UTC に上書きされていれば、動くのは UTC です。「php.iniを見たから大丈夫」で終わらせず、実際に走っているプロセスから値を出すのが確実です。
DBについては、②で確認した @@session.time_zone が SYSTEM になっていることがあります。これは「サーバーのOSに合わせる」という意味なので、DBサーバーのOS設定を見に行く必要があります。また、Asia/Tokyo のような名前でタイムゾーンを指定したいときは、MySQLではタイムゾーンテーブルの読み込みが必要な場合があります。名前で指定してエラーになるときは、+09:00 のようなオフセット指定で確認すると切り分けが進みます。
コンテナで動かしている場合は、ホストのタイムゾーンがコンテナに引き継がれないのが既定です。イメージの中は UTC のまま、というのはよくある構成です。ここは環境差で事故らない設定管理の話とつながります。
④ ズレる時間帯で再現する
原因の見当がついたら、再現させます。再現しないまま直すと、直ったかどうか確かめられません。
- 9時間の食い違いを疑うなら、日本時間の0時〜9時にあたる時刻のデータを、検証環境に作って通します。実際に深夜まで起きている必要はありません。テストデータの日時を「深夜1時」にして流せば足ります。
- 月末を疑うなら、月末日付のデータで動かします。
- 日付の境界を疑うなら、23時59分59秒と0時0分1秒の2件を用意します。ここが通れば、大半の境界バグは拾えます。
検証環境が本番と同じタイムゾーン設定になっているかは、先に確認してください。ここが違うと、再現しなかった理由が分からなくなります。
⑤ 直す場所をひとつに決める
原因が分かったあと、直せる場所は複数あります。DBの設定を変える、アプリの設定を変える、SQLを直す、表示側で変換する。どれでも症状は消えます。だからこそ、どこで直すかを先に決めて、1か所だけ触るのが安全です。
判断の目安はこうです。
- 設定漏れ(アプリのタイムゾーンが未設定) → アプリの設定で直す。影響範囲は広いが、根っこがそこなら他の場所も一緒に直ります。
- SQLの範囲指定ミス(
<=の落とし穴) → そのSQLだけ直す。他に波及させない。 - 既存データがUTCで入っていて、表示だけ直したい → 表示側の変換で直す。保存済みのデータを書き換えるのは最後の手段です。
- 保存されている値そのものが間違っている → データ修正が必要になります。その場合はSQLでデータ修正をするときの安全手順に沿って、件数確認とバックアップを先にしてください。
いちばんやってはいけないのは、表示側で「9時間足す」を書いて辻褄を合わせることです。その場は直りますが、根っこが直った日に、今度は9時間ズレて戻ります。数年後の担当者(自分かもしれません)が、なぜ9時間足しているのか分からずに悩みます。応急処置としてやむを得ないときは、コメントに理由と、いつまでの暫定かを書き残しておくと、まだ救いがあります。暫定対応と恒久対応を分けて管理するのやり方が、そのまま使えます。
影響:見つけにくいわりに、じわじわ効く
日付のズレは、システムが止まるタイプの障害ではありません。だからこそ、気づかれないまま長く続くことがあります。
- 売上や利用状況の集計が、少しずつ実態とずれる。判断の材料が狂う。
- 締め日をまたぐ処理で、対象が漏れる/二重に入る。
- 有効期限や予約の判定が、境界の1件だけ通ってしまう/弾かれる。
- 利用者から「日付が違う」と指摘されたとき、こちらの数字が正しいと言い切れなくなる。
逆に言えば、一度きちんと突き止めておくと、同じ形のバグは以後まとめて片づきます。4つの層の設定を書き出しておけば、次に「1日ズレる」が来たとき、幅を測って設定表を見るだけで終わります。今回の調査は、そこそこ割に合う仕事です。
明日やること
障害が起きていなくても、明日30分で確かめておけることがあります。
- 4つの層のタイムゾーンを、それぞれ1回ずつ出力して並べる(サーバー・DB・アプリ・フレームワーク設定)。揃っているか、意図してズラしているかを確認する。
- その4行を、運用ドキュメントに書き写す。次に日付の相談が来たとき、調査の入口が5分短くなります。
- 日時カラムの型を確認する(
TIMESTAMPとDATETIME、timestamptzとtimestampが混ざっていないか)。 - 月次・日次バッチのSQLで、期間の終わりが
<=になっていないかを1本だけ見る。
全部やらなくて大丈夫です。1つ目の「並べる」だけでも、現場の見通しはかなり変わります。
今日のチェックリスト
上から優先順位で並べてあります。まずは最低ラインだけで十分です。
まずここだけ(これだけで効きます)
- ズレの「幅」を数字で出した(9時間・1日・1時間・それ以外)
- 画面の表示ではなく、DBに入っている生の値を1件見た
- サーバー・DB・アプリのタイムゾーン設定を、それぞれ出力して比べた
次に確認したい
- 日時カラムの型を確認した(変換されるタイプか、されないタイプか)
- フレームワーク側の設定が、
php.iniなどの設定を上書きしていないか確認した - 期間指定のSQLで、終わりが
<= 月末になっていないか見た - 日本時間の0時〜9時にあたるデータで再現を試した
- 検証環境と本番のタイムゾーン設定が同じか確認した
余力があれば
- 月末・月初のデータで、月の加算・減算の挙動を確かめた
- 外部APIから受け取る日時に、タイムゾーンの情報(
+09:00やZ)が付いているか確認した - コンテナで動かしている場合、イメージ内のタイムゾーンを確認した
- 直す場所を1か所に決めてから手を入れた
- 「9時間足す」のような辻褄合わせを入れていない(入れた場合は理由をコメントに残した)
- 4つの層の設定を運用ドキュメントに書き残した
最低ラインの3つが済んでいれば、もう原因は半分絞れています。残りは、余裕のある日に少しずつで大丈夫です。

日付のバグを追いかけているとき、いちばんしんどいのは「自分の書いたコードが読めていないのでは」と思えてくる時間だと思います。でも、この種のバグは読解力の問題ではありません。目に見えないところで、時計が4つ動いているだけです。見えないものは、読んでも見えなくて当たり前です。
幅を測って、生の値を見て、設定を並べる。この3つを順にやれば、たいていのズレは正体を現します。今日その1件を突き止められたなら、同じ形で困っている未来の何件かも、まとめて片づいています。全部を一度にきれいにしなくて大丈夫です。まずは、4つの時計を並べて見るところから。
よければ、こちらも
- サーバーの時刻ずれが引き起こす障害|NTPの確認と直し方
- 再現しないバグの調査の進め方|環境・タイミング依存を疑う
- 環境差で事故らない設定管理|開発・検証・本番の違いを整理する
- SQLでデータ修正をするときの安全手順|件数確認とバックアップ
保守運用の実務ヒントを、メールでお届けしています。よかったら受け取ってください。