管理画面に 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" みたいな文字列を Date や datetime に変換していれば、全員が踏みうる。
まず手元で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-05 と 2026-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時か持っていない
tzinfo が None。この 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 < aware で TypeError。ローカルでは 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/strftimeにtimeZone/ 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と出ても、それはもう事故じゃなく仕様になる。
参考にした一次情報:
- Date - MDN
- Date.parse() - MDN(日付だけの文字列がUTC扱いになる根拠)
- Intl.DateTimeFormat - MDN
- datetime - Python公式
- zoneinfo - Python公式