0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

保存したはずの日付が、1日前にズレていた — 犯人はタイムゾーンなしの日時文字列

0
Last updated at Posted at 2026-07-04

管理画面に 2026-07-05 と入れて保存した。翌日ニューヨークのメンバーが同じ画面を開いたら 2026-07-04 になっていた。DBを見ると値は正しい。ズレていたのは表示の1行だけ。原因を追ったら、new Date("2026-07-05") が自分の思っている日付じゃなかった。

結論だけ先に置く。タイムゾーンを持たない日時文字列は、書いた瞬間ではなく「読む側の環境」で意味が決まる。 だから保存した本人と、別の国で開いた人とで、日付が1日ずれる。これは JavaScript でも Python でも同じ理屈で起きる。

入れた文字列 東京で見ると ニューヨークで見ると
Before(TZなし) 2026-07-05 7/5 7/4
After(オフセット付き) 2026-07-05T00:00:00+09:00 7/5 7/4(同じ瞬間として正しく変換)

見た目は同じ「7/4」でも、Before は事故で、After は仕様どおり。この差が今日の話。

この挙動は MDN が明記している。日付だけの文字列(2026-07-05)は UTC として、日付+時刻の文字列(2026-07-05T00:00:00)はローカルとして解釈される、という一次情報が Date.parse() のドキュメント にある。本記事はそれを実際に手元で再現し、Python まで含めて「なぜ1日ずれるのか」を追った作業ログ。

想定している読者は、日付を文字列でやり取りするコードを書く人全員。DBに入れる、APIで受け取る、CSVから読む——どこかで "2026-07-05" みたいな文字列を Datedatetime に変換していれば、全員が踏みうる。

まず手元で1日ズラしてみる

言葉より再現。TZ を変えて同じコードを走らせる。

// TZ=America/New_York で実行
const a = new Date("2026-07-05");
console.log(a.toISOString());          // 2026-07-05T00:00:00.000Z
console.log(a.toLocaleDateString("ja-JP"));  // 2026/7/4  ← 1日前
console.log(a.getDate());              // 4

toISOString()2026-07-05 のままなのに、ローカルの日付を取り出す getDate()toLocaleDateString()4 を返す。UTC の 7/5 0時は、ニューヨーク(UTC-4)ではまだ 7/4 の 20時だから。保存側(東京)は 7/5 のつもり、表示側(NY)は 7/4。DBの値は1ミリも動いていないのに、画面の数字だけが1日ずれる。

犯人は「タイムゾーンを持たない文字列」

2026-07-05 という文字列には、それが「どこの7月5日か」という情報が入っていない。TZ が無い日時を、パーサは勝手に補う。JavaScript の場合、日付だけの文字列は UTC と決め打ちする。ここが直感とずれる入り口。

さらに厄介なのが、同じ見た目でも「時刻まで書いたか」で解釈が変わる点。

// TZ=Asia/Tokyo で実行
new Date("2026-07-05").toISOString();          // 2026-07-05T00:00:00.000Z (UTC扱い)
new Date("2026-07-05T00:00:00").toISOString(); // 2026-07-04T15:00:00.000Z (ローカル=JST扱い)
// 差 = 9時間

2026-07-052026-07-05T00:00:00。人間には「同じ7月5日0時」に見えるのに、前者は UTC、後者はローカル(JST)として解釈され、9時間ずれる。どちらもよく書く文字列なので、混在した瞬間にバグる。

Python でも同じ穴に落ちる

これは JavaScript 固有の話じゃない。Python の datetime も、TZ 情報のない文字列をパースすると「naive(TZなし)」な値になる。

from datetime import datetime, timezone, timedelta

s = "2026-07-05T00:00:00"
naive = datetime.fromisoformat(s)
print(repr(naive))       # datetime.datetime(2026, 7, 5, 0, 0)
print(naive.tzinfo)      # None ← どこの0時か持っていない

tzinfoNone。この naive をどう解釈するかは、後続コード次第でブレる。UTC のつもりで表示するか、ローカルのつもりで UTC に直すかで、やはり9時間動く。

jst = timezone(timedelta(hours=9))
# UTCだと思って東京表示
print(naive.replace(tzinfo=timezone.utc).astimezone(jst))  # 2026-07-05 09:00:00+09:00
# JSTだと思ってUTC表示
print(naive.replace(tzinfo=jst).astimezone(timezone.utc))  # 2026-07-04 15:00:00+00:00

同じ naive から、9時間離れた2つの結果が出る。「どっちが正しいか」はコードのどこにも書いていない。書いた人の頭の中にしかない。それが引き継ぎでバグになる。

naive と aware を混ぜると、今度は落ちる

ずれるだけならまだ気づける。もっと質が悪いのは、TZ付き(aware)とTZなし(naive)を比較したときにいきなり落ちるパターン。

naive = datetime.fromisoformat("2026-07-05T00:00:00")  # naive
now = datetime.now(timezone.utc)                        # aware

print(naive < now)
# TypeError: can't compare offset-naive and offset-aware datetimes

naive < awareTypeError。ローカルでは naive 同士で回っていたコードが、ある日 datetime.now(timezone.utc) を混ぜた瞬間に例外を吐く。エラーメッセージは親切なほうだけど、「どこで naive が生まれたか」は自分で遡るしかない。だいたい犯人は、冒頭の fromisoformat("...TZなし文字列") だったりする。

その TZ なし文字列は、どこから湧いてくるのか

「自分はそんな雑な文字列を書いていない」と思っても、入り口は日常のあちこちに開いている。

  • <input type="date">value は、HTML仕様で常に "2026-07-05" の date-only 文字列。フォームから来た瞬間に TZ は落ちている。
  • JSON には日付型が無い。 Date を JSON に載せると文字列になり、JSON.parse で戻しても Date には復元されない。
const json = JSON.stringify({ due: new Date("2026-07-05T00:00:00+09:00") });
console.log(json);                       // {"due":"2026-07-04T15:00:00.000Z"}
console.log(typeof JSON.parse(json).due); // string ← Dateに戻らない

APIをまたぐたびに日時は文字列に潰れる。受け取った側が new Date(...)fromisoformat(...) で戻すとき、TZ の付け忘れが混ざる。つまりこの穴は「自分のミス」というより、JSONとフォームの構造上、通り道に必ずある

なぜ「9時間」でも「1日」でもズレるのか

ずれ幅が9時間になったり1日になったりするのが混乱の元なので、整理しておく。

ずれの正体は常に「UTC とローカルの境界をまたいだか」だけ。東京は UTC+9 なので、UTC の 0時0分〜8時59分は、東京ではまだ「同じ日」に収まる。ところがニューヨーク(UTC-4)は UTC より後ろにいるので、UTC の 7/5 0時はまだ 7/4。時刻の見た目は9時間差でも、それが日付の変わり目をまたぐと「1日」として現れる。 9時間ずれと1日ずれは別の現象じゃなく、同じ境界問題の見え方の違い。

だから「うちのサーバーは日本だから大丈夫」は通用しない。ブラウザは相手の国で動くし、UTC で動くクラウド上のジョブから見れば、東京の 7/5 早朝はまだ 7/4。読む側の TZ が一つでも違えば、日付はいつでもまたぎうる。

直す — 3つのルール

再現できたので直す。難しいテクニックは要らない。順番を守るだけ。

ルール1: 「いつ」はオフセット付きISO8601で確定させる。 TZ を文字列に埋め込めば、読む側が誰でも同じ瞬間を指す。オフセットを付ける前と後で、環境間のズレは 9時間 → 0時間 に変わる。

// TZ=America/New_York で実行しても結果は変わらない
const t = new Date("2026-07-05T00:00:00+09:00"); // 東京の7/5 0時と明示
console.log(t.toISOString()); // 2026-07-04T15:00:00.000Z

ルール2: 表示は「端」で、TZを明示して変換する。 内部は UTC(または aware)で持ち回し、画面に出す最後の1行だけ TZ を指定して整形する。

const fmt = (tz) =>
  new Intl.DateTimeFormat("ja-JP", { timeZone: tz, dateStyle: "short" }).format(t);
console.log(fmt("Asia/Tokyo"));       // 2026/07/05
console.log(fmt("America/New_York")); // 2026/07/04

これで NY が 7/4 と出るのは、もうバグじゃない。同じ瞬間を、その人の時計で正しく見ているだけ。事故と仕様の違いはここ。

ルール3: Python は最初から aware にする。 zoneinfo(標準ライブラリ)で TZ を貼れば、naive は生まれない。

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

t = datetime(2026, 7, 5, 0, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
print(t.astimezone(timezone.utc).isoformat())          # 2026-07-04T15:00:00+00:00
print(t.astimezone(ZoneInfo("America/New_York")).date()) # 2026-07-04
print(t < datetime.now(timezone.utc))                  # True ← aware同士なので比較も通る

naive を作らなければ、9時間ずれも TypeError も両方消える。

それでも残るワナ

直したつもりでも踏み続けるポイントがあるので、正直に書いておく。

  • 日付だけ扱いたいときに時刻を混ぜると逆にバグる。 「誕生日」「締切日」のように本来 TZ を持たない値まで datetime に入れると、変換のたびに日がずれる。この場合は日時ではなく「日付型(date / date-only)」として持つほうが安全。時刻を持たせないのが正解のケースがある。
  • サマータイム(DST)がある地域はオフセットが年2回変わる。 +09:00 決め打ちが通じるのは日本にDSTが無いから。米欧を相手にするなら固定オフセットではなく America/New_York のような 地域名(IANA TZ) で持つこと。zoneinfo / Intl はここを自動で吸収してくれる。
  • Python 3.11 未満の fromisoformat は入力が狭い。 末尾 Z を受け付けないバージョンがある。パース元が信頼できないなら、対応バージョンを datetime のドキュメント で確認するか、パーサ側を固定する。

万能の一手はない。「文字列にTZを持たせる」「内部はUTC/aware」「表示は端でTZ指定」の3点を守っても、上の3つは個別に気をつける必要がある。

今日 / 今週 / 今月やること

  • 今日(5分): 自分のコードで new Date("fromisoformat( を grep する。引数がTZなし文字列("2026-..."+Z も無い)なら候補。grep -rnE 'new Date\("20|fromisoformat\(' . で一覧が出る。
  • 今週: 日付を「表示」している箇所を洗い、toLocaleDateString / strftimetimeZone / TZ変換が入っているか確認する。入っていなければ、そこがサーバーTZ依存でずれる場所。
  • 今月: DBやAPIの境界で日時をどう持っているかを1枚に書き出す。文字列で渡している箇所は「オフセット付きISO8601 or UTCのZ形式」に統一し、date で足りる値は datetime から降格させる。

まとめ

日付が1日ずれるバグは、たいてい賢いバグじゃない。2026-07-05 という、TZを持たない文字列を、別のTZの環境で読んだだけ。JavaScript は日付だけの文字列を UTC 扱いし、Python は naive を静かに作る。どちらも「どこの日付か」を書き忘れた文字列が引き金。

直し方は地味だ。文字列にTZを埋める。内部はUTC(aware)で回す。表示の最後だけTZを指定して変換する。この順番を守れば、NYで7/4と出ても、それはもう事故じゃなく仕様になる。

参考にした一次情報:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?