業務アプリの日時とタイムゾーン ── DateTimeの罠からUTC保存の原則、テスト設計まで

· 更新日: · · C#, .NET, .NET Framework, Windows, タイムゾーン, 日時処理, テスト, 運用, 技術相談

更新履歴(4件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
「9時間ずれ」の実験を`FakeTimeProvider`で代用できると書いていたのを直しました。`SetLocalTimeZone`が差し替えるのはその`TimeProvider`の`LocalTimeZone`プロパティだけで、プロセス全体の`TimeZoneInfo.Local`は変わりません。実験のコードが呼んでいる`ToLocalTime()`/`ToUniversalTime()`が見るのは後者なので、フェイクを差してもCIのマシン設定の結果がそのまま出ます。裏を返すとこれが`ToLocalTime()`を業務コードから追い出す理由でもあるため、変換先を引数で受け取るテスト可能な形の例に置き換えました。7.2節の記述にも、注入した時計を経由しているコードにしか効かない旨を補っています。
冒頭に用語表と目的別の読み筋を追加しました。手元で9時間ずれを再現する5分の実験(最小コードと`tzutil`での切り替え、JSTとUTCでの結果対比)を新設し、UTCで保存して表示時に変換するパイプラインの図、業務日付とタイムスタンプを分けたテーブル設計例を追加しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589923)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「業務アプリの日時とタイムゾーン ── DateTimeの罠からUTC保存の原則、テスト設計まで」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589923 https://staging.comcomponent.com/blog/business-app-datetime-timezone-guide/

DOI(最新版)
10.5281/zenodo.21589923
DOI(この版)
10.5281/zenodo.21732917

「サーバーをクラウドの VM に移設したら、帳票の時刻が全部 9 時間ずれた」「海外拠点に配った端末だけ、日報の日付が前日になる」「深夜の集計バッチが、ある日だけ 2 回動いた形跡がある」。日時とタイムゾーンにまつわる相談は、この手の「環境が変わった途端に」という形でやってきます。コードは 1 行も変えていないのに、です。

「うちは日本国内専用のアプリだから、タイムゾーンは関係ない」と思われがちですが、実際の相談の多くはまさにその国内専用アプリで起きています。クラウドの VM やコンテナーは UTC 設定で払い出されることが多く、海外製のライブラリや SaaS の Web API は UTC やオフセット付きのタイムスタンプを返してきます。アプリ自身は日本時間だけで生きているつもりでも、境界の向こう側はとっくに UTC の世界です。「この時刻はどこ基準か」を明示しないまま値をやり取りしていると、サーバー移設やクラウド化の日に 9 時間ずれとして一気に顕在化します。

この記事では .NET の業務アプリを前提に、DateTime の Kind と暗黙変換の罠、DateTimeOffset との使い分け、「保存と通信は UTC またはオフセット付き、表示だけローカル」という原則、TimeZoneInfo と夏時間、DB との境界、そして TimeProvider を使ったテスト設計までを一通り整理します。設計レビューで毎回確認している項目を、チェックリストの形に落とすところまでやります。

この記事の対象読者と読み筋

対象読者は、.NET(C#)で業務アプリを作る・保守する開発者です。「うちは国内専用だからタイムゾーンは関係ない」と思っている方こそ想定読者です。コード例は C# / .NET 8 で書いていますが、.NET Framework でも使える範囲は都度注記します。

長い記事なので、目的別の読み筋を先に置きます。

目的 読む場所
国内専用・小規模で、最低限だけ押さえたい 1 章(結論)→ 2 章(Kind の罠)→ 3 章(UTC 保存の原則)→ 6 章(DB との境界)。この 4 つで事故の大半は防げます
いま起きているずれの原因を切り分けたい 2 章、特に 2.1 節の再現手順 → 8 章の「レビュー時に grep で拾うコード」
海外拠点・海外 SaaS 連携がある 上に加えて 4 章(タイムゾーン ID)と 5 章(夏時間)
年跨ぎや月末のバグをテストで再現したい 7.2 節(TimeProvider
新規設計のレビュー観点だけ欲しい 8 章のチェックリスト

先に押さえておく用語

本文に略語のまま出てくる用語を、先にまとめておきます。

用語 意味
UTC (Coordinated Universal Time) 協定世界時。世界共通の基準時刻で、日本時間(JST)は UTC+9 です
ISO 8601 日時の文字列表記を定めた国際規格。2026-07-03T13:30:00+09:00 のような形式です
IANA tz database 世界のタイムゾーン定義と過去の改定履歴をまとめたデータベース。IANA (Internet Assigned Numbers Authority) が管理し、Asia/Tokyo のような ID を定めています(4.1 節)
ICU (International Components for Unicode) 国際化処理を担う定番ライブラリ。.NET 5 以降はカルチャやタイムゾーンの解決をここに任せています(4.1 節)1
NLS (National Language Support) ICU 以前から Windows が持つ国際化 API。.NET を NLS モードで動かすこともできますが、その場合 IANA ID は解決できません1
DST (Daylight Saving Time) 夏時間。日本には現在ありませんが、海外拠点や海外 SaaS との連携では踏みます(5 章)
Kind DateTime が持つ「どこ基準か」の属性。Utc / Local / Unspecified の 3 値です(2 章)

1. まず結論

1 行でいうと、「取得・保存・通信は UTC(またはオフセット付き)、ローカル時刻にするのは画面に出す直前の 1 回だけ」です。以下はその内訳と、破ったときに何が起きるかです。

  • 日時事故の根本原因はほぼ 1 つ、「この時刻はどこ基準か」という情報を持たない値が境界(DB・API・ファイル)を越えることです。コードではなく環境が変わった日に発症します。
  • DateTimeKind(Utc / Local / Unspecified) という属性を持ち、既定は Unspecified です。ToLocalTime は Unspecified を UTC とみなしToUniversalTimeローカルとみなすという非対称な暗黙解釈があり、これが「9 時間ずれ」の典型的な発生源です。2
  • 新規コードの既定は DateTimeOffset にします。公式ガイダンスも「アプリケーション開発の既定の日時型として検討せよ」と明記しています。3
  • 原則は「保存と通信は UTC またはオフセット付き、表示だけローカル」。文字列にするときは ISO 8601 準拠のラウンドトリップ書式 “o” で書き出し、DateTimeStyles.RoundtripKind で読み戻します。4
  • タイムゾーン変換は TimeZoneInfo で行います。.NET 6 以降は IANA ID(Asia/Tokyo)と Windows ID(Tokyo Standard Time)の両方が使え、相互変換 API もあります5 ただし Windows での IANA 解決は ICU 依存で、古い Windows Server や不変グローバリゼーション構成では失敗します(4 章)。1
  • 日本に夏時間はなくても、海外拠点の端末・海外 SaaS との連携・UTC 設定のサーバーを経由した瞬間に、DST の「存在しない時刻・曖昧な時刻」を踏みます。6
  • DateTime.Now の直書きをやめ、TimeProvider(.NET 8 標準。旧環境は Microsoft.Bcl.TimeProvider)を注入して、時刻をテストで自由に動かせる設計にします。7

この記事の知識マップ

業務アプリの日時事故は、DateTimeが既定で持つKind=Unspecifiedの値にToLocalTime/ToUniversalTimeが黙って基準を仮定することに起因し、実行マシンのタイムゾーン設定に依存するため開発機では再現せずサーバー移設の日に9時間ずれとして顕在化する。対策はUTC保存の原則で、取得・保存・通信はDateTimeOffsetやUTCのDateTimeで統一し、ラウンドトリップ書式で境界を越え、ローカル時刻への変換は表示直前の1回に限る。TimeZoneInfoによる変換はKindとの整合を要求し、IANA IDの解決はWindows上ではICUに依存し、夏時間の切替日にはIsInvalidTime/IsAmbiguousTimeで異常な時刻を確認できる。SQL Serverのdatetime2列やEF Coreの値コンバーターはDB境界でのKind消失に対応し、TimeProviderを注入すれば年跨ぎやDST切替日のバグをテストで再現できる。

業務アプリの日時とタイムゾーン処理の知識マップDateTimeのKindが引き起こす9時間ずれ事故の構造と、UTC保存の原則がそれをどう防ぐか、TimeZoneInfoやDST、DBとの境界、TimeProviderによるテストとの関係を示す図原因になり得る防止する軽減する前提とする利用する利用する前提とするで確認できる原因になり得る軽減する利用する前提とする利用する原因になり得る軽減する原因になり得る前提とする利用する利用する利用する推奨される対応DateTimeのKindUTC保存の原則サーバー移設時の9時間ずれ事故DateTimeOffsetTimeZoneInfoIANAタイムゾーンIDWindowsタイムゾーンIDICU(International Components for Unicode)夏時間(DST)DST切替日の定期実行の多重実行・欠測ラウンドトリップ書式("o")業務日付とタイムスタンプの区別EF Coreの値コンバーターSQL Serverのdatetime2型DB読み出し時のKind消失NTP(Network Time Protocol)TimeProvider.NET(Core以降).NET Framework

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. DateTimeのKind ── 3値の意味と暗黙変換の事故

DateTime は日時の値(Ticks)に加えて、Kind という属性を 1 つだけ持っています。値は 3 つで、既定は Unspecified です。2

Kind 意味 主な発生源
Utc UTC 基準の時刻 DateTime.UtcNowToUniversalTime() の結果
Local 実行マシンのローカルタイムゾーン基準 DateTime.NowToLocalTime() の結果
Unspecified どこ基準か不明(既定) new DateTime(...)DateTime.Parse(多くの場合)、DB からの読み出し

重要なのは、普通にコードを書くと Unspecified だらけになることです。コンストラクターで作った値も、文字列からパースした値も、DB から読み出した値も、既定では「どこ基準か不明」です。それ自体は問題ではありません。問題は、この Unspecified な値に対して変換メソッドが黙って基準を仮定することです。2

呼び出し Kind=Utc Kind=Local Kind=Unspecified
ToUniversalTime() そのまま返す UTC へ変換 ローカルとみなして UTC へ変換
ToLocalTime() ローカルへ変換 そのまま返す UTC とみなして ローカルへ変換

同じ Unspecified が、呼ぶメソッドによって「ローカル」とも「UTC」とも解釈される、という非対称がポイントです。コードで見るとこうなります。

// DB から読み出した値。多くの経路で Kind = Unspecified になる
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

// 実行マシンが JST (UTC+9) の場合:
Console.WriteLine(fromDb.ToLocalTime());      // 18:00 ── UTC とみなされ +9 時間
Console.WriteLine(fromDb.ToUniversalTime());  // 00:00 ── ローカルとみなされ -9 時間

事故はこの形で起きます。DB に日本時間で保存された 09:00(Unspecified)を、表示前の「念のための変換」のつもりで ToLocalTime() すると、UTC とみなされて 18:00 になります。逆に「保存前に UTC へ揃えよう」という変換が経路のどこかに二重に入っていると、9 時間が二度引かれます。さらに厄介なのは、この挙動が実行マシンのタイムゾーン設定に依存することです。開発機(JST)では 9 時間、UTC 設定のサーバーでは 0 時間ずれるので、「手元では再現しません」という問い合わせになります。冒頭の「サーバー移設で 9 時間ずれた」は、たいていこの構図です。

2.1 手元で「9時間ずれ」を再現する ── 5分のミニ実験

「開発機では再現しません」を卒業する一番早い方法は、開発機のタイムゾーンを変えて同じコードを走らせてみることです。5 分で終わります。

まず、次のコンソールアプリを用意します。

// .NET 8 / C#。DBから読み出した「日本時間の9:00」を模した Unspecified な値
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

Console.WriteLine($"OSのタイムゾーン  : {TimeZoneInfo.Local.Id}");
Console.WriteLine($"Kind              : {fromDb.Kind}");
Console.WriteLine($"ToLocalTime()     : {fromDb.ToLocalTime():HH:mm}");
Console.WriteLine($"ToUniversalTime() : {fromDb.ToUniversalTime():HH:mm}");

手順は次のとおりです。タイムゾーンの切り替えには Windows 標準の tzutil を使います(変更が拒否される場合はコマンドプロンプトを管理者として実行してください)。

  1. tzutil /g で現在のタイムゾーン ID を控えます(あとで戻すため)。日本設定の開発機なら Tokyo Standard Time が表示されます。
  2. そのまま実行して、結果を控えます。
  3. tzutil /s "UTC" でタイムゾーンを UTC に変え、アプリを起動し直して同じ結果を見ます。TimeZoneInfo.Local はプロセス内でキャッシュされるので、実行しっぱなしでは切り替わりません。
  4. tzutil /s "Tokyo Standard Time" で元に戻します。

同じコード・同じ入力値で、結果はこう変わります。

実行マシンのタイムゾーン ToLocalTime() ToUniversalTime()
Tokyo Standard Time(UTC+9) 18:00(UTC とみなされて +9 時間) 00:00(ローカルとみなされて -9 時間)
UTC 09:00(変化なし) 09:00(変化なし)

これが「開発機では再現しない」の正体です。Unspecified な値に変換を掛けるコードは、実行マシンの設定という外部状態で結果が変わるため、JST の開発機では派手に 9 時間ずれ、UTC のクラウド VM ではまったくずれません。逆に、ローカル時刻で保存してきたデータを UTC のサーバーへ移設すると、それまで打ち消し合っていたずれが表に出ます。

この実験を FakeTimeProvider で代用することはできません。FakeTimeProvider.SetLocalTimeZone が差し替えるのはその TimeProviderLocalTimeZone プロパティだけで、プロセス全体の TimeZoneInfo.Local は変わりません。7 上のコードが呼んでいる ToLocalTime() / ToUniversalTime() が見るのは後者なので、フェイクを差してもCI のマシン設定の結果がそのまま出ます

裏を返すと、これが ToLocalTime() を業務コードから追い出す理由でもあります。プロセス全体の設定を読むメソッドは、テストから差し替えられません。タイムゾーンの扱いを回帰テストに載せたいなら、変換先を引数として受け取る形にします。

// .NET 8 / C#。変換先を外から渡す。TimeProvider を注入しているなら
// provider.LocalTimeZone を渡せばよい
static DateTime JstWallClockToUtc(DateTime wallClock, TimeZoneInfo zone)
    => TimeZoneInfo.ConvertTimeToUtc(
           DateTime.SpecifyKind(wallClock, DateTimeKind.Unspecified), zone);

// テスト: 実行マシンのタイムゾーン設定に関係なく、同じ結果になる
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
Assert.Equal(
    new DateTime(2026, 7, 3, 0, 0, 0, DateTimeKind.Utc),          // 9:00 JST = 0:00 UTC
    JstWallClockToUtc(new DateTime(2026, 7, 3, 9, 0, 0), tokyo));

7.2 節の FakeTimeProvider が効くのは、対象のコードが provider.GetLocalNow()provider.LocalTimeZone を経由している場合です。DateTime.NowToLocalTime() を直接呼んでいるコードには届きません。マシンの設定そのものが結果を変えることを確かめたいなら、上の tzutil の手順が結局いちばん確実です。

2.2 DateTimeOffsetとの違いと使い分け

DateTimeOffset は日時に加えて UTC からのオフセット+09:00 など)を常に持つため、値単体で世界のどの瞬間かが一意に決まります。ログの記録、取引時刻、システムイベントの記録といった「時点の記録」用途では、公式ガイダンスが DateTimeOffset を既定の日時型として検討するよう明記しています。3 Kind の暗黙解釈に頼る余地がそもそもないので、この記事で扱う事故の大半が構造的に起きなくなります。

ただし万能ではありません。DateTimeOffset が持つのはオフセットであってタイムゾーンではない点に注意してください。+09:00 が日本なのか韓国なのかは分かりませんし、夏時間の調整規則も持ちません。3 「その土地の壁掛け時計の動き」を再現したい場合は、後述の TimeZoneInfo を組み合わせる必要があります。使い分けを表にします。

持っている情報 向いている用途 補足
DateTimeOffset 日時+UTC オフセット 発生時刻の記録、ログ、API 境界 新規コードの既定3
DateTime(Kind=Utc 運用) 日時のみ 内部計算、既存資産との互換 Kind の管理は全部自前
DateOnly / TimeOnly 日付のみ/時刻のみ 業務日付、営業時間、締め時刻 .NET Framework では使えない3
TimeSpan 時間間隔 経過時間、2 時点の差  
TimeZoneInfo タイムゾーンの定義(調整規則含む) 変換、夏時間の判定 4 章

既存の DateTime 資産を全部 DateTimeOffset に書き換えるのは現実的でないことが多いので、当社が改修案件でよく採る折衷案は「内部と保存は Kind=Utc の DateTime で統一し、境界(API・シリアライズ)は DateTimeOffset か “o” 書式文字列」です。どちらにしても、次章の原則が土台になります。

3. 原則 ── 保存と通信はUTCかオフセット付き、表示だけローカル

日時処理の設計原則は 3 行にまとまります。

  1. 発生時刻は DateTime.UtcNow または DateTimeOffset.UtcNow で取得し、UTC(またはオフセット付き)のまま持ち回る
  2. 境界(DB・API・ファイル・レジストリ)を越えるときは、書式と基準を仕様として明示する
  3. ローカル時刻への変換は、画面・帳票に出す直前の 1 回だけ

図にすると、変換が入ってよい場所は 1 か所だけです。

やってはいけない発生・取得DateTimeOffset.UtcNowTimeProvider.GetUtcNow()アプリ内部UTC のまま持ち回す比較・並べ替えもUTCで行うDBdatetime2 に UTC で保存列名は CreatedAtUtcAPI・ファイル・ログISO 8601 のラウンドトリップ書式 o2026-07-03T04:30:00.0000000Z表示直前の変換 1回だけTimeZoneInfo.ConvertTimeFromUtc変換先は利用者や拠点の設定で決める画面・帳票その土地の壁時計時刻ローカル時刻のまま保存・送信値の意味がOS設定に依存する移設した日に9時間ずれる

ローカル時刻で保存する設計は、「値の意味」をサーバーの OS 設定という外部状態に依存させる設計です。オンプレの日本設定サーバーで動いている間は問題が見えませんが、クラウド VM への移設、海外リージョンでの DR 構成、開発環境と本番の設定差──どれか 1 つで意味が変わります。UTC 保存なら、値の意味はどの環境でも同じです。表示用の変換は利用者側の設定(またはユーザーマスタの拠点タイムゾーン)で行うので、同じデータを東京とベルリンで見ても、それぞれ正しい壁時計時刻になります。

3.1 境界の文字列表現はISO 8601 / “o”書式

文字列で境界を越えるとき(JSON、CSV、ログ、設定ファイル)は、ISO 8601 準拠のラウンドトリップ書式 “o” を使います。”o” は DateTime の Kind、DateTimeOffset のオフセットを文字列に残し、DateTimeStyles.RoundtripKind を指定してパースすれば元の値に戻せます。4

using System.Globalization;

// 書き出し: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);

// 読み戻し: オフセットを保ったまま復元される
var restored = DateTimeOffset.Parse(
    s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);

CultureInfo.InvariantCulture を必ず併用してください。カルチャ既定のままだと、和暦などグレゴリオ暦以外のカルチャで動く端末で年の表記が変わります。逆にやってはいけないのが、"yyyy/MM/dd HH:mm" のような基準情報を持たない書式での保存・通信です。この文字列を受け取った側は「どこ基準か」を推測するしかなく、推測は環境が変わると外れます。なお System.Text.Json の既定の日時表現も ISO 8601 系なので、JSON 境界では素直に既定へ乗るのが安全です。

「境界を越えるデータは形式を仕様に明示する」という考え方は、日時に限りません。文字コードと改行コードでまったく同じ議論を「Windowsの文字コードと改行コード」でしています。境界の暗黙は、種類が違っても同じ形で壊れます。

3.2 タイムスタンプと「業務日付」を区別する

もう 1 つ、冒頭の「海外拠点だけ日報の日付が前日になる」を解くための区別です。タイムスタンプ(世界で一意の時点)と業務日付(「7 月 3 日の日報」というラベル)は別物です。業務日付を「深夜 0 時の DateTime」で持って UTC 変換を通すと、UTC+9 の 7 月 3 日 00:00 は UTC の 7 月 2 日 15:00 になり、日付部分を切り出した瞬間に前日へずれます。これが典型的な発生機序です。

業務日付は DateOnly(.NET Framework なら yyyy-MM-dd 形式の文字列や年月日の値)で持ち、「どのタイムゾーンで日付を切るか」を仕様で決めます。「日報の日付は拠点の現地時刻基準」「締めは本社 JST 基準」のように書いてあれば、実装は UtcNow を該当タイムゾーンへ変換してから日付を切るだけです。仕様に書いていないなら、それは実装者のマシン設定が仕様になっているということです。

テーブル設計に落とすと、両者は列の型からして別物になります。SQL Server を例にすると次のようになります(型の詳細は 6.1 節)。

用途 列の例(SQL Server) .NET 側の型 ねらい
タイムスタンプ(発生した時点) created_at_utc datetime2(3) NOT NULL DateTime(Kind=Utc) UTC で保存し、列名に基準を焼き込む(6.3 節)
タイムスタンプ(現地時刻の再現が要件) signed_at datetimeoffset(3) NOT NULL DateTimeOffset 入力時点のオフセットまで残す
業務日付(日報・締めのラベル) report_date date NOT NULL DateOnly 時刻もタイムゾーンも持たせない
日付を切る基準のタイムゾーン site_time_zone_id varchar(64) NOT NULL stringTimeZoneInfo へ解決) 拠点マスタに IANA ID で持つ(4.1 節)
締め時刻・営業時間 closing_time time(0) NOT NULL TimeOnly 「毎日 17:30」に日付を持たせない

要は、業務日付の列に時刻を持つ型を使わないことです。report_datedatetime2 にして 00:00 を入れた瞬間に、この節の冒頭で書いた前日ずれの経路が復活します。逆に date 型なら、UTC 変換を通す余地がそもそもありません。

4. タイムゾーン変換 ── TimeZoneInfoとID体系

タイムゾーン変換は TimeZoneInfoConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime で行います。注意すべきは、これらの API が DateTime の Kind と変換元タイムゾーンの整合を検査することです。たとえば Kind=Utc の値を「変換元は東京」として渡すと ArgumentException になります。8 つまり Kind の管理がいい加減なコードは、タイムゾーン変換 API すらまともに呼べません。2 章の話はここに直結します。

4.1 WindowsのタイムゾーンIDとIANA ID

タイムゾーンを指定する ID には 2 つの体系があります。

  Windows ID IANA ID
例(日本) Tokyo Standard Time Asia/Tokyo
例(ドイツ) W. Europe Standard Time Europe/Berlin
管理元 Windows(レジストリ) IANA tz database
主な利用先 Windows API、.NET (Framework) Linux、海外 SaaS、Web API、他言語

.NET Framework 時代は Windows ID しか使えず、「Web API から Asia/Tokyo が飛んでくるが FindSystemTimeZoneById に渡せない」という変換表問題がありました。.NET 6 以降は TimeZoneInfo.FindSystemTimeZoneById が両方の ID を受け付け、システムに無い側の ID は自動で変換して解決します。明示的に変換したい場合の TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId も追加されています。5

// .NET 6+ : IANA ID がそのまま通る(Windows ID "Tokyo Standard Time" でも同じ結果)
var tokyo  = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo));   // 東京の壁時計時刻
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin));  // ベルリンの壁時計時刻

// ID 体系の相互変換 (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

実務の指針としては、拠点マスタや設定ファイルに保持するタイムゾーン ID は IANA ID に統一するのがおすすめです。海外 SaaS・Linux コンテナー・他言語との連携で共通に通じるのは IANA ID であり、.NET 側は 6 以降なら原則そのまま受けられます。.NET Framework で動く部分が残っている場合は、その境界でだけ Windows ID へ変換します。なお FindSystemTimeZoneById は ID が見つからないと TimeZoneNotFoundException を投げるので、マスタに ID を登録する画面や起動時の検証で早期に弾いてください。

ひとつ重要な前提条件があります。Windows 上での IANA ID の解決は ICU ライブラリに依存します。NLS モードやグローバリゼーション不変モード(InvariantGlobalization=true)で動いているアプリでは IANA ID は解決できず、TryConvertIanaIdToWindowsId も失敗します。1 また、OS に ICU が同梱されていない古い環境(Windows Server 2019、Windows 10 の 1809 以前など)で .NET 6 を動かす場合も、アプリローカル ICU を同梱しない限り同じ制約を踏みます(.NET 7 以降はこれらの OS でも ICU が使われるよう変更されています9)。つまり「開発機(Windows 11)では Asia/Tokyo が通るのに、客先の Server 2019 だけ TimeZoneNotFoundException」が現実に起きます。IANA ID をマスタに採用する場合は、(1) 実行環境の OS と .NET バージョンで IANA 解決が動くことを確認する、(2) コンテナー等でサイズ目的の InvariantGlobalization を安易に有効化しない、(3) 保険として TryConvertIanaIdToWindowsId で Windows ID に落として再試行するフォールバックを起動時検証に入れておく──の 3 点をセットにしてください。

5. 夏時間(DST)── 日本国内専用でも踏む場面

日本には現在夏時間がないため、「DST はうちには関係ない」と思われがちです。しかし次のどれかに当てはまると、確実に踏みます。

  • 海外拠点・海外出張者の端末でアプリが動く(端末のローカルタイムゾーンが DST を持つ)
  • 海外製 SaaS / Web API と連携し、現地時刻ベースのタイムスタンプやスケジュールを受け取る
  • 海外リージョンのサーバー・VM で集計やバッチが動く
  • 海外拠点の現地時刻で締める集計(「各拠点の 0 時締め」など)がある

DST を持つタイムゾーンでは、切り替え日に 2 種類の異常な時刻が生まれます。存在しない時刻(春、時計が進む瞬間にスキップされる帯。ドイツなら 3 月末の 02:00〜03:00)と、曖昧な時刻(秋、時計が戻ることで 2 回現れる帯)です。.NET では TimeZoneInfo.IsInvalidTime / IsAmbiguousTime で判定できます。6 そして ConvertTimeToUtc などの変換 API は、存在しない時刻を渡すと ArgumentException を投げ、曖昧な時刻は標準時側として解釈します8

var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// 2026-03-29 はドイツの DST 開始日。02:00〜03:00 の現地時刻は存在しない
var t = new DateTime(2026, 3, 29, 2, 30, 0);   // Kind = Unspecified

Console.WriteLine(berlin.IsInvalidTime(t));    // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) は ArgumentException になる

「現地時刻の文字列を受け取って UTC に変換して保存する」という入力経路があるなら、この例外は年に 1 日だけ発生するバグとして潜伏します。入力検証の段階で IsInvalidTime を確認し、曖昧な時刻は「標準時として扱う」ことを仕様に明記しておくのが現実的な落としどころです。

5.1 定期実行とDST ── 2回動く・動かない問題

定期実行はもう 1 つの定番の被弾ポイントです。現地時刻の 02:30 に毎日動くジョブは、DST 開始日にはその時刻が存在せず、終了日には 2 回存在します。スケジューラーの実装によって「スキップされる」「2 回起動する」「1 時間ずれて起動する」と挙動が分かれるため、「毎日 1 回だけ動く」前提で書かれた集計処理が、二重集計または欠測を起こします。対策は 3 つの組み合わせです。

  • スケジュール基準を UTC(または DST の無いタイムゾーン)にする。現地時刻での起動が要件でないバッチは、これだけで問題が消えます
  • 処理を冪等にする。「対象日の集計が既に存在すればスキップ」という実行済みマーカーを入れれば、2 回起動しても壊れません
  • 集計キーは業務日付で持つ(3.2 節)。起動時刻から日付を逆算しない

タスクスケジューラでの定期実行の設計(多重起動防止、失敗時の切り分け)は「タスクスケジューラのタスクが実行されない・0x1で終わる」で、常駐サービス側でタイマーを持つ設計は「Windowsサービスの作り方と運用」で詳しく書いています。どちらの方式でも、DST とスケジュールの関係は同じように仕様へ書いておく必要があります。

6. DBとの境界 ── SQL Server / SQLite / ORM

境界の中でいちばん事故が多いのが DB です。DB の日時型は「どこ基準か」を保持しないものが多く、保存した瞬間に Kind やオフセットの情報が失われるからです。

6.1 SQL Serverの日時型

範囲・精度 基準情報 新規採用の目安
datetime 1753 年〜、約 1/300 秒精度 なし 避ける(公式が新規利用を非推奨と明記)10
datetime2 0001 年〜、最大 100 ナノ秒精度 なし ◎ UTC で保存する列の本命10
datetimeoffset datetime2 相当+オフセット オフセットを保持 ○ 現地時刻の再現が要件の列10

datetime は丸め粒度が粗く範囲も狭い旧世代の型で、公式ドキュメントが「新規の作業では避け、datetime2 / datetimeoffset などを使うこと」と明記しています。10 既存スキーマの datetime を無理に移行する必要はありませんが、新しいテーブルで選ぶ理由はありません。

datetime2 に UTC で保存するか、datetimeoffset にするかは、「入力時点のオフセットを後から再現する必要があるか」で決めます。監査やコンプライアンスで「利用者の現地時刻で何時だったか」を残す要件があるなら datetimeoffset、時点さえ特定できればよいなら UTC の datetime2 で十分です。ただし datetimeoffset が持つのもオフセットだけで、タイムゾーン(調整規則)そのものではない点は DateTimeOffset と同じです。ゾーンまで要るなら IANA ID を別列で持ちます。

6.2 SQLiteには日時型がない

SQLite にはそもそも日時のストレージ型がなく、Microsoft.Data.SqliteDateTime / DateTimeOffsetTEXT として保存します。11 TEXT の ISO 8601 系書式は「書式とタイムゾーンが統一されていれば」文字列ソート=時刻ソートになりますが、逆に言えば UTC とローカルが 1 列に混ざった瞬間、ソートも範囲検索も静かに壊れます。SQLite を使う場合は「この列は UTC、書式はこれ」をアプリ側の規約として固定するしかありません。接続やトランザクションも含めた SQLite の実務は「C#でSQLiteを業務アプリに使う」にまとめています。

6.3 EF Core / Dapperでの注意 ── 読み出すとKindが消える

基準情報を持たない型(datetime2、SQLite の TEXT など)から DateTime を読み出すと、当然ながら Kind は Unspecified になります。「保存時は UTC で統一したのに、読み出した値を ToUniversalTime() している箇所があって二重変換になった」という事故はここから生まれます。対策は境界で Kind を復元することです。EF Core なら値コンバーターで一括して宣言できます。

// EF Core: 「この列は UTC」をモデル側で一括宣言する
modelBuilder.Entity<Order>()
    .Property(o => o.CreatedAtUtc)
    .HasConversion(
        // 書き込み: UTC 以外(DateTime.Now の混入など)も境界で UTC に正規化する。
        // Unspecified はローカルとみなして変換される点に注意
        v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
        // 読み出し: Kind を復元
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

書き込み側の正規化はあくまで最後の保険です。Unspecified の変換は実行マシンのタイムゾーン設定に依存するので、DateTime.Now や Unspecified な値を保存経路に流し込むコード自体は見つけ次第直します。保険と規約の二段構えにする、という理解でいてください。Dapper や素の ADO.NET なら、マッピング直後に DateTime.SpecifyKind を通す詰め替え層を 1 か所に集めます。あわせて効くのが命名規約で、列名・プロパティ名に基準を焼き込むCreatedAtUtcupdated_at_utc)だけで、レビューで「この値に ToUniversalTime は変だ」と気づける確率が大きく上がります。ドキュメントより名前のほうが読まれるからです。

7. 時刻の同期とテスト ── w32timeとTimeProvider

7.1 マシンの時計は合っている前提を疑う

ここまでの話は「マシンの時計自体は正しい」前提でしたが、その時計を合わせているのは Windows Time サービス(w32time)です。w32time は NTP でネットワーク上の時刻源と同期し、Active Directory 環境ではドメイン階層に沿って同期します。Kerberos 認証をはじめ、時刻ずれに敏感な仕組みの土台です。12

アプリ設計への含意は 2 つです。第一に、クライアント PC の時計を業務ロジックの根拠にしないこと。同期が止まった端末は平気で数分ずれるので、イベントの順序や締め時刻の判定はサーバー側の時刻で行い、クライアント時刻は参考情報に留めます。第二に、「時刻がおかしい」という問い合わせでは、アプリより先に w32tm /query /status で端末の同期状態を確認すること。アプリのバグ調査を始める前に 1 分で切り分けられます。

7.2 DateTime.Now直書きをやめる ── TimeProvider

日時処理のテストを阻む最大の要因は、コードのあちこちに直書きされた DateTime.Now です。「月末締めの判定」「年跨ぎの連番リセット」「DST 切替日のスケジュール」をテストしたくても、現在時刻が固定できなければ、その日が来るまで検証できません。

.NET 8 からは標準の時刻抽象 TimeProvider が入りました。GetUtcNow() / GetLocalNow() / LocalTimeZone / タイマー生成までを 1 つの抽象で差し替えられます。.NET Framework 4.6.2 以降と .NET Standard 2.0 でも NuGet の Microsoft.Bcl.TimeProvider で同じ型が使えるので、古い資産でも導入できます。テスト用の実装 FakeTimeProvider は Microsoft.Extensions.TimeProvider.Testing パッケージで提供されます。7

public sealed class DailyReportService
{
    private readonly TimeProvider _clock;
    private readonly TimeZoneInfo _siteTimeZone;

    public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
    {
        _clock = clock;
        _siteTimeZone = siteTimeZone;
    }

    // 業務日付は「拠点のタイムゾーンで日付を切る」と明示した実装 (3.2 節)
    public string GetReportDateKey()
    {
        var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
        return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }
}

本番では TimeProvider.System を渡し、テストでは FakeTimeProvider で時刻を自由に固定・前進させます。

[Fact]
public void 年跨ぎでも業務日付が正しく切り替わる()
{
    // JST の大晦日 23:30 に固定して開始
    var clock = new FakeTimeProvider(
        new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
    var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
    var svc = new DailyReportService(clock, tokyo);

    Assert.Equal("2026-12-31", svc.GetReportDateKey());

    clock.Advance(TimeSpan.FromHours(1));   // 年跨ぎを一瞬で再現
    Assert.Equal("2027-01-01", svc.GetReportDateKey());
}

FakeTimeProvider は時刻の固定・手動前進に加えてローカルタイムゾーンの差し替え(SetLocalTimeZone)もできるので、「ドイツ設定の端末で日付が前日になる」ようなバグを CI 上の日本のマシンで再現できます。ただし効くのは、対象のコードが provider.GetLocalNow()provider.LocalTimeZone を経由している場合だけです。差し替わるのはそのプロバイダーが返すタイムゾーンであって、プロセス全体の TimeZoneInfo.Local ではないので、7 DateTime.NowToLocalTime() を直接呼んでいるコードには届きません(2.1 節)。「注入した時計を経由していないコードは、テストから差し替えられない」という当たり前の帰結です。.NET Framework の古い案件で NuGet 追加すら難しい場合は、DateTimeOffset UtcNow { get; } だけの自前 IClock インターフェイスでも効果は同じです。重要なのは抽象の豪華さではなく、「現在時刻」を注入可能な依存として扱うことです。

テストケースとして最低限入れておきたい時刻は、経験上この 5 つです: 年末年始(年跨ぎ)、月末(31 日・30 日・2 月)、うるう年の 2 月 29 日、対象タイムゾーンの DST 切替日(春・秋)、深夜 0 時前後(業務日付の境界)。どれも「その日にだけ」動くバグの定番生息地で、FakeTimeProvider があれば全部を数ミリ秒でテストできます。

8. チェックリストと判断表

日時処理は、UUID と同じく「なんとなく動いているから」で放置されがちな基盤要素です(「UUIDは衝突しないのか」で書いた構図とよく似ています)。新規設計とレビューで見る項目を固定しておくと、属人性が消えます。

新規設計時のチェックリスト:

  • 発生時刻の取得は DateTimeOffset.UtcNow / TimeProvider.GetUtcNow() に統一されているか
  • 保存・通信の基準(UTC かオフセット付きか)と書式(”o” / ISO 8601)が仕様書に明記されているか
  • DB の列型と基準が決まっているか(SQL Server なら datetime2(UTC) か datetimeoffset。列名に Utc を焼き込む)
  • タイムスタンプと業務日付を区別し、日付を切るタイムゾーンを決めたか
  • タイムゾーン ID の管理体系(IANA ID 推奨)と、不正 ID 時のエラー処理を決めたか
  • 定期実行の DST 方針(UTC 基準スケジュール+冪等化)を決めたか
  • TimeProvider / IClock が注入可能で、時刻境界のテストケースがあるか

レビュー時に grep で拾うコード:

見つけたら疑うコード 何が起きうるか 直し方
DateTime.Now サーバー移設でずれる。テスト不能 UtcNow +表示時変換。TimeProvider 注入
ToLocalTime() / ToUniversalTime() Unspecified の暗黙解釈(2 章) 境界で Kind を確定させ、表示直前だけ変換
DateTime.Parse(s)(styles 指定なし) 実行環境のカルチャ・TZ 依存 ParseExact + InvariantCulture + RoundtripKind
ToString("yyyy/MM/dd HH:mm") での保存・通信 基準情報が消える “o” 書式+ InvariantCulture
new DateTime(...) の比較・保存への直接使用 Unspecified 混入 SpecifyKind するか DateTimeOffset
SQL Server の新規 datetime 精度・範囲・将来性 datetime2 / datetimeoffset10

このチェックリストの大半は、新規開発なら最初の 1 日で決められる内容です。逆に稼働後の修正は、保存済みデータの基準を推定してマイグレーションする作業(どの期間のデータがどの基準で入ったかの考古学)になり、コストが 1 桁変わります。

9. まとめ

日時とタイムゾーンの事故は、症状こそ「9 時間ずれる」「日付が前日になる」「バッチが 2 回動く」とばらばらですが、原因は一貫して基準情報を持たない値が境界を越えることです。対策も一貫しています。取得は UTC、保存と通信は UTC またはオフセット付き+ ISO 8601(”o”)、表示だけローカル。境界では書式と基準を仕様に明示し、DB の列名に基準を焼き込む。タイムゾーンは TimeZoneInfo と IANA ID で扱い、DST の存在しない時刻・曖昧な時刻は入力検証と冪等な定期実行で受け止める。そして TimeProvider を注入して、年跨ぎも DST 切替日もテストで再現できるようにしておく──ここまでやれば、環境が変わった日に呼び出される側から、変わる前に指摘する側に回れます。

当社では、サーバー移設・クラウド化に伴う時刻ずれの原因調査、日時処理まわりの設計レビュー、海外拠点対応(タイムゾーン・DST 対応)の改修支援を扱っています。保存済みデータの基準がすでに混在してしまったケースの棚卸しと移行計画も含めて、判断に迷う場合はお手伝いできます。

関連記事

関連する相談領域

合同会社小村ソフトでは、業務アプリの日時・タイムゾーン処理の設計レビュー、サーバー移設やクラウド化に伴う時刻ずれ・日付ずれの原因調査、海外拠点対応やレガシー資産の日時処理改修を扱っています。

参考リンク

  1. Microsoft Learn, .NET globalization and ICU. Windows での IANA タイムゾーン ID の解決と相互変換 API が ICU に依存すること、NLS モード・グローバリゼーション不変モードでは利用できないこと、ICU を持たない OS ではアプリローカル ICU で対応できることについて。  2 3 4

  2. Microsoft Learn, DateTime.Kind Property. Kind の既定値が Unspecified であること、Kind の値が ToLocalTime / ToUniversalTime の変換結果に与える影響(Unspecified が ToLocalTime では UTC、ToUniversalTime ではローカルとみなされること)について。  2 3

  3. Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. DateTimeOffset をアプリケーション開発の既定の日時型として検討すべきこと、DateTimeOffset がオフセットのみを持ちタイムゾーンには紐づかないこと、DateOnly / TimeOnly が .NET Framework では利用できないことについて。  2 3 4 5

  4. Microsoft Learn, Standard date and time format strings. ラウンドトリップ書式 “o” が ISO 8601 準拠で DateTime の Kind・DateTimeOffset のオフセットを文字列に保持すること、DateTimeStyles.RoundtripKind でパースし直すことで往復できることについて。  2

  5. Microsoft Learn, What’s new in .NET 6. .NET 6 で TimeZoneInfo.FindSystemTimeZoneById が IANA / Windows 両方のタイムゾーン ID を受け付けて自動変換すること、TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId が追加されたことについて。  2

  6. Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. 夏時間への切り替えで生じる「存在しない時刻」の定義と判定方法、対になる IsAmbiguousTime(曖昧な時刻の判定)について。  2

  7. Microsoft Learn, What is TimeProvider?. TimeProvider が .NET 8 以降に標準搭載され、.NET Framework 4.6.2+ / .NET Standard 2.0 では Microsoft.Bcl.TimeProvider パッケージで利用できること、GetUtcNow / GetLocalNow / LocalTimeZone / タイマー生成の抽象、テスト用の FakeTimeProvider が Microsoft.Extensions.TimeProvider.Testing パッケージで提供されることについて。TimeProvider.LocalTimeZone は「その TimeProvider の時間の捉え方に従ったローカルタイムゾーン」を返すプロパティで、既定の実装が TimeZoneInfo.Local を返すもの、と定義されています。つまり FakeTimeProvider で差し替えられるのはそのプロバイダー経由の変換だけで、プロセス全体の TimeZoneInfo.Local(DateTime.ToLocalTime() などが参照する値)は変わりません。  2 3 4

  8. Microsoft Learn, TimeZoneInfo.ConvertTime Method. DateTime.Kind と変換元タイムゾーンの整合が要求され不一致で ArgumentException になること、曖昧な時刻が標準時として解釈されること、存在しない時刻を渡すと ArgumentException になることについて。  2

  9. Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. .NET 7 以降、ICU を同梱しない Windows Server 2019 等でも ICU ライブラリが使われるよう変更されたこと、それ以前はアプリローカル ICU の手動配置が必要だったことについて。 

  10. Microsoft Learn, datetime (Transact-SQL). 新規の作業では datetime を避け、time / date / datetime2 / datetimeoffset を使うべきこと、datetime2 / datetimeoffset の精度が高く datetimeoffset がタイムゾーンオフセットをサポートすることについて。  2 3 4 5

  11. Microsoft Learn, Data types (Microsoft.Data.Sqlite). SQLite のプリミティブ型が 4 つしかなく、Microsoft.Data.Sqlite が DateTime / DateTimeOffset を TEXT として保存することについて。 

  12. Microsoft Learn, Windows Time Service (W32Time). Windows Time サービスが NTP でネットワーク上のコンピューターの時刻を同期すること、Active Directory ドメインでの同期階層、Kerberos 認証が時刻同期に依存することについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

サーバー移設後に時刻が9時間ずれるのはなぜですか?
根本原因は、「この時刻はどこ基準か」という情報を持たない値がDB・API・ファイルなどの境界を越えることです。.NETのDateTimeは既定でKindがUnspecifiedになり、ToLocalTime()はUTCとみなして+9時間、ToUniversalTime()はローカルとみなして-9時間の変換を黙って行います。この挙動は実行マシンのタイムゾーン設定に依存するため、日本時間の開発機では問題が見えず、UTC設定のクラウドVMへ移設した日に一気に顕在化します。
DateTimeとDateTimeOffsetはどちらを使うべきですか?
新規コードの既定はDateTimeOffsetです。UTCからのオフセットを常に持つため値単体で世界のどの瞬間かが一意に決まり、Kindの暗黙解釈による事故が構造的に起きません。公式ガイダンスもアプリケーション開発の既定の日時型として検討するよう明記しています。ただしオフセットはタイムゾーンそのものではないため、夏時間の調整規則が必要な場合はTimeZoneInfoを組み合わせます。既存のDateTime資産が多い場合は、内部と保存をKind=UtcのDateTimeで統一し、境界だけDateTimeOffsetにする折衷案も現実的です。
日本国内専用のアプリでも夏時間(DST)対策は必要ですか?
海外拠点や海外出張者の端末で動く、海外製SaaSやWeb APIと連携する、海外リージョンのサーバーでバッチが動く、のいずれかに当てはまれば必要です。DSTを持つタイムゾーンでは切り替え日に「存在しない時刻」と「曖昧な時刻」が生まれ、TimeZoneInfoの変換APIは存在しない時刻を渡すとArgumentExceptionを投げます。現地時刻の02:30に動く定期ジョブは、DST開始日にスキップされたり終了日に2回動いたりするため、UTC基準のスケジュールと冪等な処理設計が対策になります。
日時処理のテストはどうやって書けばよいですか?
DateTime.Nowの直書きをやめ、.NET 8標準のTimeProviderを注入して現在時刻を差し替え可能にします。.NET Framework 4.6.2以降でもMicrosoft.Bcl.TimeProviderパッケージで同じ型が使えます。テストではFakeTimeProviderで時刻を固定・前進でき、ローカルタイムゾーンの差し替えも可能なので、年跨ぎやDST切替日のバグをCI上で再現できます。最低限テストすべき時刻は、年末年始、月末、うるう年の2月29日、DST切替日、深夜0時前後の5つです。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る