toISOString() で日付が1日ずれる——1,271ファイルを機械で走査したら12件見つかった原因と直し方
new Date().toISOString().slice(0, 10) で作った日付は、日本時間の午前0時から午前9時までに実行すると1日前になります。原因は toISOString() が世界標準時(UTC)へ変換してから文字列にするためで、日本時間はUTCより9時間進んでいるからです。私たちは自動化スクリプトでこれを踏み、日次レポートが前日のファイルを上書きして消す事故を起こしました。この記事では、直し方(JST固定オフセット)と、自社の5リポジトリ1,271ファイルを走査して12件の未修正箇所を見つけた実測までを書きます。

なぜ1日ずれるのか
toISOString() は、時刻を必ずUTCに直してから ISO 8601 形式の文字列にします(MDN(新しいタブで開く))。末尾の Z がその印です。
日本時間(JST)はUTCより9時間進んでいます。つまり日本の8月14日 午前7時03分は、UTCではまだ8月13日 22時03分です。ここで先頭10文字を切り出すと 2026-08-13 になります。
// 日本時間 2026-08-14 07:03 に実行した場合
new Date().toISOString().slice(0, 10); // → "2026-08-13" ← 1日前厄介なのは、午前9時以降に動かすと正しい値が返ることです。手元で試すときはたいてい日中なので、テストは通り、レビューも通り、深夜や早朝に走る本番だけが静かに壊れます。
ずれた日付が「どこに置かれているか」で被害が変わる
同じ1行でも、その日付が何に使われるかで結果はまったく違います。私たちのレポート生成スクリプトは、1つの値が「ファイル名」「履歴のキー」「集計期間の終わり」を同時に決めていたため、被害が3つ重なりました。
| 日付の使い道 | ずれた時に起きること | 気づきやすさ |
|---|---|---|
| 出力ファイル名 | 前日の成果物を上書きして消す | 中(消えた物を探せば分かる) |
| 履歴データのキー | その日の記録が生まれない・前日に混ざる | 低 |
| 集計期間の終わり | 前回と同じ期間を再集計し「前回比 同値」と出る | 最低(安定して見える) |
| 画面表示だけ | 表示が1日ずれる | 高(見れば分かる) |
実測では、2026年8月14日 午前7時03分に走った便が report-2026-08-13.md を上書きし、集計期間も前日のまま据え置かれました。直してから測り直したところ、「前回と同じ」と報告される寸前だった数字が全部動いていました(訪問60→55、平均滞在117.6秒→89.8秒)。同値は安定ではなく、昨日の再掲だったわけです。
直し方:JST固定オフセットで日付を作る
対策はシンプルで、UTCに変換する前に9時間足してしまうことです。日本には夏時間がないので、固定の9時間で厳密に成立します。
const JST_OFFSET_MS = 9 * 3600 * 1000;
/** JST(+09:00)のカレンダー日付を YYYY-MM-DD で返す */
const iso = (d) => new Date(d.getTime() + JST_OFFSET_MS).toISOString().slice(0, 10);
const today = new Date();
const daysAgo = (n) => new Date(today.getTime() - n * 864e5); // setDate は環境のTZ依存なのでミリ秒で引く環境変数 TZ に頼らないのが要点です。cron や launchd から起動されるプロセスは、対話シェルと同じ環境変数を引き継ぐとは限りません。動く環境に依存しない純粋な関数にしておくと、テストも書けます。
もうひとつ、setDate() で「n日前」を作るのも同じ理由で危険です。こちらもミリ秒の引き算に寄せました。私たちは最初、日付だけ直して期間の終わり側を直し忘れるという中途半端な修正をしています。
いちばん怖いのは「毎回ずれている」ほう
修正の翌日、同じ罠を踏んでいる箇所が他にもないか探したところ、毎朝のメール配信スクリプトが見つかりました。これが本当の発見でした。
このジョブは毎朝8時47分(日本時間)に走ります。UTCに直すと前日の23時47分で、100%の実行が危険な時間帯の中にありました。結果として、日報の「昨日の送信件数」は新設から8日間ずっと一昨日の数字を報告し続けていました。
ここから引き出した教訓は、定常的な誤りは、変動する誤りより見つからないということです。レポート生成のほうは27便のうち2便(約7%)しかずれず、翌朝すぐ気づけました。メールのほうは毎日ずれていたのに、8日間誰も気づきませんでした。
理由は単純で、一貫して間違っていると比較対象がなく、「そういう数字」に見えるからです。「前回と違う」を軸にした監視は、最初から間違っている値を全部素通りさせます。
私たちの監視のほとんどは差分を見る型でした。ここでは差分ではなく、外部の基準(この場合は日本の暦)と突き合わせる検査が必要でした。同じ構造の失敗はAI自動化の「無言の失敗」にも書いています。
注意書きではなく、機械に列挙させる
いちばん苦かったのは、知識はすでに社内にあったという事実です。姉妹スクリプトには、こういうコメント付きの正しい実装が前から入っていました。
ローカル日付のYYYY-MM-DD。toISOString() はUTC変換されJSTで日付が1日ずれるため使わない片方は罠を知って避け、もう片方は踏んでいた。書かれた知見は、書かれた場所から動きません。だから対策をドキュメントの追記ではなく、全ファイルを見に行く検査スクリプトにしました。
.mjs.js.ts.shを再帰的に走査し、toISOString()やdate -uで作った日付を拾う- 拾った行を用途で仕分ける。バッチのファイル名・永続キー・集計期間なら赤、画面表示だけなら情報
- 文字列リテラルの中の一致は除外する(修正記録の文章まで赤くなると、直すほど検査が汚れる)
- 意図的に安全な箇所は、同じ行のコメントで理由付きで除外できるようにする
- cronやlaunchdの起動時刻を突き合わせ、午前0〜9時に走るジョブを最優先で直す
単純な grep では表示用の無害な日付まで拾ってノイズだらけになるので、2の仕分けが実質的な設計です。私たちは「自動化コード=赤 / プロダクトの実行時コード=参考 / JST済み=緑」の3階層にしました。
2026年8月29日時点の走査結果は、1,271ファイル中 赤12件・参考17件・JST済み14件です。検査を作った8月14日の初回は1,006ファイル中の赤5件でしたから、母数が増えるより速いペースで新しい違反が生まれていることになります。注意書きが増えても、書き手が変われば同じ罠は踏まれます。検査を回し続けることだけが効きました。定期実行そのものの設計はClaude Codeを定期実行して開発を進める構成にまとめています。
よくある質問
Q. `toISOString()` は使ってはいけないのですか。 いいえ、使い道次第です。ログのタイムスタンプのように「絶対時刻を記録する」用途ではUTC表記が正解で、むしろ推奨されます。危ないのは、その文字列を日本時間のカレンダー上の「今日」として扱うときだけです。
Q. `TZ=Asia/Tokyo` を環境変数で指定すれば済みませんか。
toISOString() は環境変数の TZ に関係なく必ずUTCを返すので、これでは直りません。toLocaleDateString("sv-SE", { timeZone: "Asia/Tokyo" }) のようにタイムゾーンを明示する方法もありますが、私たちはテストしやすい純粋な関数にしたかったため固定オフセットを選びました。
Q. すでにずれたデータが混ざっています。どう直しますか。 まず新規の書き込みを止める(コードを直す)のが先です。過去分は、ジョブの起動時刻がわかれば「危険時間帯に走った便だけがずれている」と機械的に特定できます。ファイルの更新時刻と中身の日付が1日食い違う行を探すのが実務的でした。
Q. どの時間帯に走るジョブから直せばよいですか。 日本時間の午前0時から午前9時に起動するものが100%ずれるので最優先です。次に、深夜バッチや実行時間の長いジョブで日をまたぐ可能性があるものを見てください。日中しか走らないジョブは実害ゼロなので後回しで構いません。
この記事の数値は、私たち自身の自動化基盤(5リポジトリ・日次バッチ運用)の実測です。他の環境で同じ比率になるとは限りませんが、「日付を作っている1行が、何を決めているか」を数えるという点検の型はそのまま使えるはずです。
コメント
#AI開発#自動化#開発の裏側