C# DateTime と DateTimeOffset の違い・タイムゾーン処理の正解(業務SE本番事故編)

みなさんこんにちは!ヒロポンです!!

今回は業務系の C# でガチで本番事故るやつ!!の話。

「受発注ログの時刻が1時間ズレてて、ユーザーから『私が登録した時刻と表示が違う』って問い合わせ来た」「SQL Server に DateTime 入れて読み出したら Kind が変わってた」「JSON で連携した時刻が海外拠点で別の時刻として解釈された」みたいな時刻のズレ事故って、業務SEなら誰しも一回はやらかしますよね??

俺も2社目くらいの流通系SIer時代に、受発注ログの時刻が1時間ズレてる事故をやらかして、夕方になってユーザーから問い合わせ来て半日デバッガで追ってハマった経験があります。原因は完全に DateTime.Kind = Unspecified で、SQL Server から読み出した時刻に ToUniversalTime() を呼んでた箇所が JST → UTC 変換と勘違いされて、実際には Local とみなされて二重変換になってたやつ。

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 / SQL Server 2016 環境で、C# 時刻処理の 6つの定石(DateTime Kind / Now vs UtcNow / DateTimeOffset / TimeZoneInfo / SQL Server datetime2 / JSON ISO8601)を、コード7本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • DateTime.Kind を毎回確認(Unspecified 同士の比較は罠)/サーバー処理は UtcNow 寄せ
  • 複数タイムゾーン跨ぎなら DateTimeOffset、変換は TimeZoneInfo.ConvertTime で明示的に
  • SQL Server は datetime2 推奨(datetime だと .NET DateTime とミリ秒ズレ)/JSON は ISO 8601 統一
目次

定石1: DateTime.Kind の3つの罠(Local / Utc / Unspecified)

DateTime には Kind というプロパティがあって、Local / Utc / Unspecified の3つの値を取る。これが業務SEで一番踏みやすい罠っす:

// ✅ 定石1: DateTime.Kind の3パターン
var local = DateTime.Now;                                  // Kind=Local
var utc   = DateTime.UtcNow;                                // Kind=Utc
var unsp  = new DateTime(2026, 5, 8, 14, 30, 0);            // Kind=Unspecified

Console.WriteLine($"Local: {local.Kind}");      // Local
Console.WriteLine($"Utc  : {utc.Kind}");        // Utc
Console.WriteLine($"Unsp : {unsp.Kind}");       // Unspecified

// ❌ 罠: Unspecified を ToUniversalTime() すると Local とみなされる
var converted = unsp.ToUniversalTime();         // 内部で Local とみなして変換
Console.WriteLine($"Converted: {converted}");   // JST 環境なら -9時間されてる

// ✅ 正しい書き方: 明示的に Kind を指定
var jstUnsp = DateTime.SpecifyKind(unsp, DateTimeKind.Local);
var jstUtc  = jstUnsp.ToUniversalTime();        // 期待通りの変換

DateTime.SpecifyKind(value, DateTimeKind.Local) で Kind を確定させてから変換に渡すのが鉄則。SQL Server から DataReader で読んだ DateTime はデフォルトで Unspecified になるので、読み出し直後に SpecifyKind で Kind を確定させる運用が事故防止になります。

ん?普通に動くんじゃないの??って思うかもだけど、Unspecified の挙動は ToUniversalTime() だと Local 扱い、ToLocalTime() だと UTC 扱い という非対称さがあって、コードを読んでも気付きにくいんですよね。これでハマると半日溶ける。

定石2: DateTime.Now と DateTime.UtcNow の使い分け

サーバー処理の時刻ログ・DB 保存は UtcNow 寄せ が事故防止になります:

// ✅ 定石2: サーバー処理は UtcNow、UI 表示だけ Now
public class OrderLog
{
    public Guid OrderId { get; set; }
    public DateTime CreatedAtUtc { get; set; }  // ← UtcNow で記録

    public static OrderLog Create(Guid orderId)
        => new OrderLog { OrderId = orderId, CreatedAtUtc = DateTime.UtcNow };

    // UI 表示はローカル時刻に変換
    public string DisplayCreatedAt()
        => CreatedAtUtc.ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss");
}

業務系で「サーバーは AWS の us-east-1 / ユーザーは JST」「海外拠点の支社からも見る」みたいな環境では、サーバー側ログを UTC 統一 しておくと、表示側でローカル時刻に変換すれば全拠点で正しく見える。

逆に、サーバー側で DateTime.Now(ローカル時刻)でログを取ると、サーバーのタイムゾーン設定が変わった瞬間にログが遡って意味不明になる事故が起きる。クラウド移行・サーバー移転のタイミングで踏みやすい。最初から UtcNow 寄せにしておくといい感じに防げます。

「DST(サマータイム)はうちは関係ない」と思ってる現場でも、DateTime.Now のローカル時刻はサーバー側 OS のタイムゾーンに依存 するので、新規開発なら UtcNow 寄せが現代的な選択っす。

定石3: DateTimeOffset を使うべき場面

DateTimeOffset内部で UTC 値 + オフセット情報の両方を保持 する型。複数タイムゾーン跨ぎではこれが本命:

// ✅ 定石3: DateTimeOffset でタイムゾーン情報を持ち回る
DateTimeOffset jstNow = DateTimeOffset.Now;             // JST 環境なら +09:00
DateTimeOffset utcNow = DateTimeOffset.UtcNow;          // +00:00

Console.WriteLine($"JST: {jstNow:yyyy-MM-dd HH:mm:ss zzz}");  // 2026-05-08 14:30:00 +09:00
Console.WriteLine($"UTC: {utcNow:yyyy-MM-dd HH:mm:ss zzz}");  // 2026-05-08 05:30:00 +00:00

// 海外拠点のオフセットを明示的に作る
var nyTime = new DateTimeOffset(2026, 5, 8, 1, 30, 0, TimeSpan.FromHours(-4));  // EST
Console.WriteLine($"NY : {nyTime:yyyy-MM-dd HH:mm:ss zzz}");  // 2026-05-08 01:30:00 -04:00

// 比較は内部で UTC に統一されるので安全
bool isSameInstant = jstNow.UtcDateTime == nyTime.UtcDateTime;

DateTime だと「JST の14:30」と「UTC の05:30」が同じ瞬間であることをコードから読み取れないけど、DateTimeOffset ならオフセット情報が型に含まれてるので、比較・ソート・ログ表示が安全

新規開発で時刻を扱うなら、原則 DateTimeOffset 寄せ。DateTime は既存コードベースとの整合性で使うくらいに留めるのが現代的な判断軸です。

定石4: TimeZoneInfo.ConvertTime で明示的タイムゾーン変換

UTC ↔ JST ↔ EST のような明示的なタイムゾーン変換TimeZoneInfo.ConvertTime を使う:

// ✅ 定石4: TimeZoneInfo で明示的タイムゾーン変換
var utcNow = DateTime.UtcNow;

// JST に変換(.NET Framework / Windows: "Tokyo Standard Time")
TimeZoneInfo jstZone = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
DateTime jstTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, jstZone);

// EST に変換
TimeZoneInfo estZone = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime estTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, estZone);

Console.WriteLine($"UTC: {utcNow:yyyy-MM-dd HH:mm:ss}");
Console.WriteLine($"JST: {jstTime:yyyy-MM-dd HH:mm:ss}");
Console.WriteLine($"EST: {estTime:yyyy-MM-dd HH:mm:ss}");

// DST 込みで自動計算される(EST/EDT の切り替え)
var summerUtc = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc);
var summerEst = TimeZoneInfo.ConvertTimeFromUtc(summerUtc, estZone);  // EDT (-04:00)

ポイント:

  1. TimeZoneInfo.FindSystemTimeZoneById の ID は OS 依存(Windows: "Tokyo Standard Time"、Linux/Mac: "Asia/Tokyo")
  2. DST(サマータイム)は自動で考慮される(EST/EDT の切り替えが内部で走る)
  3. ToUniversalTime() / ToLocalTime() よりも意図が明示的で、コードレビューで分かりやすい

.NET 6+ なら TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") の IANA ID もクロスプラットフォームで使えるんだけど、.NET Framework 4.7.2 環境だと Windows 形式 ID で書く必要がある点に注意です。

定石5: SQL Server datetime vs datetime2 — 業務系 JOIN 事故の温床

SQL Server の datetime は精度が約 3.33 ミリ秒単位 で、.NET の DateTime(100 ナノ秒精度)とミリ秒レベルでズレが起きる。これが業務系の JOIN・WHERE 等価比較で一致しない 事故の温床になります:

-- ✅ 定石5-a: datetime と datetime2 の精度差
DECLARE @dt1 datetime  = '2026-05-08 14:30:00.123';
DECLARE @dt2 datetime2 = '2026-05-08 14:30:00.123';

SELECT @dt1 AS dt_value, @dt2 AS dt2_value;
-- @dt1: 2026-05-08 14:30:00.123 (実際は ...123、122、123 のいずれかに丸められる)
-- @dt2: 2026-05-08 14:30:00.1230000 (正確に保存される)

-- ❌ NG: datetime 同士の等価比較で一致しないことがある
SELECT * FROM order_log
WHERE created_at = @dt1;  -- ミリ秒丸めの差で一致しないケース

-- ✅ OK: datetime2 同士なら等価比較が安全
SELECT * FROM order_log
WHERE created_at = @dt2;

C# 側からの読み出しでも事故が起きる:

// ✅ 定石5-b: SQL Server から読み出した DateTime の Kind は Unspecified
using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand("SELECT created_at FROM order_log WHERE id = @id", conn))
{
    conn.Open();
    cmd.Parameters.AddWithValue("@id", orderId);
    using (var reader = cmd.ExecuteReader())
    {
        if (reader.Read())
        {
            DateTime dt = reader.GetDateTime(0);
            Console.WriteLine($"Kind: {dt.Kind}");  // Unspecified

            // ❌ NG: そのまま ToUniversalTime() すると Local とみなされる
            // ✅ OK: Kind を Utc に固定してから扱う(DB 側を UTC で保存している前提)
            DateTime utcDt = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
            DateTime jstDt = utcDt.ToLocalTime();
        }
    }
}

業務系のチェックリスト:

  • 新規テーブル → datetime2(精度の事故が消える)
  • 既存テーブル datetime → 等価比較は範囲指定(BETWEEN)に変える
  • C# 側読み出し直後に DateTime.SpecifyKind(dt, DateTimeKind.Utc) で Kind 確定
  • DB 保存時刻は UTC 統一DateTime.UtcNow 寄せ)

俺は2社目でdatetime と .NET DateTime の等価比較が一致しない事故を踏んで、半日デバッガで追ってハマった経験があります。原因は完全にミリ秒丸めで、コードを見ても分からない。SQL Server の精度仕様は知ってないと詰まる罠なので、新規はもう全部 datetime2 で統一が早い。

定石6: JSON シリアライズで ISO 8601 統一

時刻を JSON で連携する場面では、ISO 8601 形式2026-05-08T14:30:00+09:00)に統一するのが事故防止:

// ✅ 定石6: JSON シリアライズで Kind を死守する
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

public class OrderEvent
{
    public Guid OrderId { get; set; }
    public DateTime CreatedAtUtc { get; set; }       // Kind=Utc 想定
    public DateTimeOffset CreatedAt { get; set; }     // オフセット込み
}

// ❌ NG: Kind=Unspecified のまま JSON にすると受け側で Local 解釈される
var bad = new OrderEvent { CreatedAtUtc = new DateTime(2026, 5, 8, 14, 30, 0) };
string badJson = JsonConvert.SerializeObject(bad);
// → "CreatedAtUtc":"2026-05-08T14:30:00"  ← オフセット情報なし

// ✅ OK: DateTimeOffset 寄せで持つ、または Kind=Utc を明示
var good = new OrderEvent
{
    OrderId = Guid.NewGuid(),
    CreatedAtUtc = DateTime.UtcNow,                   // Kind=Utc
    CreatedAt    = DateTimeOffset.UtcNow,             // +00:00 込み
};
var settings = new JsonSerializerSettings
{
    DateTimeZoneHandling = DateTimeZoneHandling.Utc,  // 受け側で誤解されない
    DateFormatHandling = DateFormatHandling.IsoDateFormat,
};
string goodJson = JsonConvert.SerializeObject(good, settings);
// → "CreatedAtUtc":"2026-05-08T05:30:00Z","CreatedAt":"2026-05-08T05:30:00+00:00"

System.Text.Json.NET Core 3.0+ / .NET 5+)ならデフォルトで ISO 8601 出力ですが、.NET Framework 4.7.2 の業務系現場では Newtonsoft が現役なので、DateTimeZoneHandling.UtcDateFormatHandling.IsoDateFormat をシリアライザ設定に入れておくのが安全。

JSON 連携で一番踏みやすいのは「送り側は UTC のつもりだったのに、Kind=Unspecified のまま JSON にしてオフセット情報が消えて、受け側でローカル時刻と解釈される」事故。送り側でシリアライズ前に Kind を Utc に固定するか、DateTimeOffset 型で持つのが本命です。

ハマりポイント — 業務系の本番事故あるある

ここからは実際に俺が踏んだやつを3つ。流通系SIer業務系で時刻周りに手を入れる時のチェックリスト代わりにどうぞ。

1. 受発注ログ1時間ズレ事件(半日デバッガで追ってハマった)

SQL Server から DataReader で読み出した created_atToUniversalTime() で UTC 化しようとしたら、Kind=Unspecified が Local とみなされて二重変換された。実態は DB 側で UTC 保存だったのに、コードが「ローカル → UTC」変換をしたので JST 環境で -9時間ズレたログが出力された。夕方になってユーザーから「時刻が違う」報告で気づいたやつ。

それ以来、SQL Server から DateTime を読み出した直後に DateTime.SpecifyKind(dt, DateTimeKind.Utc) を毎回噛ませるラッパー関数を作って、reader.GetDateTime(0) を直接使わせない運用にしました。これでこんな感じに事故が消えます。

2. SQL Server datetime の等価比較で JOIN 結果ゼロ件(30分溶かした)

ログ集計のバッチで、datetime 型カラム同士を INNER JOIN でくっつけたら結果ゼロ件で詰まった事件。原因は2つの datetime カラムが 3.33ms 単位の丸め誤差 で一致しなかった。30分溶かした末に SQL Server の精度仕様を知って、新規テーブルは datetime2 で統一する運用に変えた。

3. JSON 連携で海外拠点の時刻が9時間ズレ(数日プロファイラで追った)

海外拠点(北米)と JSON で受発注データを連携する API で、Kind=Unspecified の DateTime をそのまま JSON 出力したら、受け側がそれを「自分のローカル時刻」と解釈して時刻が9時間ズレる事故。送り側のログには UTC で出てたので、こちら側のコードを見ても原因が分からず数日プロファイラで追ってハマった。送り側のシリアライザ設定に DateTimeZoneHandling.Utc を追加して解決。

著者の現場メモ — 業務系チームでの時刻処理ルール

流通系SIer時代に、過去コードを grep -nE 'DateTime\.Now|new DateTime\(' . でひっかけたら、70箇所近く 出てきたんですよね。書き方がバラバラで、DateTime.Now(ローカル時刻)で DB 保存してる箇所、Kind 確認なしで ToUniversalTime() 呼んでる箇所、datetime カラムと datetime2 が混在してる箇所、全部入り。

んで、後輩と一緒に 3行ルール にまとめた:

  1. サーバー側時刻は DateTime.UtcNow 寄せ(新規 DateTime.Now は禁止、UI 表示だけローカル変換)
  2. SQL Server 読み出し直後に DateTime.SpecifyKind で Kind 確定(ラッパー関数経由を推奨)
  3. 新規テーブルは datetime2、JSON シリアライズは DateTimeZoneHandling.Utc で固定

このルール化で、時刻ズレ事故が消えた。書き方を1パターンに揃えるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 + SQL Server 2016 のレガシー業務系って、DateTimeOffsetdatetime2TimeZoneInfo も普通に使える環境なのに、書き方が DateTime.Now + datetime 時代から進化してないコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。

まとめ

状況 推奨パターン
サーバー側時刻ログ・DB 保存 DateTime.UtcNow(Kind=Utc 固定)
UI 表示のローカル時刻 utcDt.ToLocalTime() で UTC から変換
複数タイムゾーン跨ぎ DateTimeOffset 寄せ(オフセット込み)
明示的タイムゾーン変換 TimeZoneInfo.ConvertTime(DST 自動考慮)
SQL Server 新規テーブル datetime2datetime は等価比較事故の温床)
SQL Server 読み出し直後 DateTime.SpecifyKind(dt, DateTimeKind.Utc)
JSON シリアライズ DateTimeZoneHandling.Utc + ISO 8601 形式

時刻処理の事故は、「Kind を確定させる」「タイムゾーン情報を持ち回る」「精度を揃える」 の3点で9割消えます。DateTime.NowDateTime.UtcNow に置き換えるだけで、サーバー移転やクラウド移行で踏む時刻ズレ事故の大半は予防できる。業務系の時刻周りは書き方を1パターンに揃えるのが本命の対処です。

よくある質問

Q1. DateTime.NowDateTime.UtcNow はどっちを使うべき?

A. サーバー側のログ・DB 保存用は UtcNow を原則にしてください。クライアント側の UI 表示だけ Now(ローカル時刻)を使う、という分け方が安全です。海外拠点のあるシステムや、AWS / Azure のサーバータイムが UTC 固定の環境では、サーバー処理時刻は UTC 統一が事故防止になります。

Q2. DateTime.Kind が Unspecified だと何が起きますか?

A. 比較や変換で予測不能な結果になります。Unspecified は「ローカル時刻でも UTC でもない、タイムゾーン情報なし」の状態で、ToUniversalTime() を呼ぶと Local とみなして変換される、ToLocalTime() を呼ぶと UTC とみなして変換される、という非対称な挙動。SQL Server から DataReader で読んだ DateTime はデフォルトで Unspecified なので、DateTime.SpecifyKind で Kind を確定させてから使うのが鉄則です。

Q3. DateTimeOffset を使うべき場面はどこですか?

A. クライアントとサーバーが別タイムゾーンで動く時、海外拠点とのデータ連携、ログのタイムスタンプを「いつ・どのオフセットで」記録したい時です。DateTimeOffset は内部で UTC 値 + オフセット情報の両方を保持するので、タイムゾーンを跨いだ時刻比較が安全になります。新規開発なら DateTime ではなく DateTimeOffset 寄せが現代的な選択です。

Q4. SQL Server の datetimedatetime2 はどっちを使うべき?

A. datetime2 を使ってください(SQL Server 2008 以降)。datetime は精度が約 3.33ms 単位で .NET の DateTime とミリ秒レベルでズレが起きるのに対し、datetime2 は 100 ナノ秒精度で .NET と完全一致します。datetime 同士の JOIN や WHERE で「等しいはずの値が一致しない」事故が業務系で頻発するので、新規テーブルは datetime2 で統一が鉄則です。

Q5. JSON シリアライズでタイムゾーン情報を残すには?

A. ISO 8601 形式(2026-05-08T14:30:00+09:00)で出力する設定にしてください。Newtonsoft.Json なら IsoDateTimeConverter(オフセット付き)、System.Text.Json なら JsonSerializerOptions のデフォルトで ISO 8601 出力です。DateTime.Kind=Unspecified のまま JSON にすると オフセット情報が失われて受け側でローカル時刻と誤解される 事故が起きるので、シリアライズ前に Kind を確定させるか、DateTimeOffset 型で持つのが安全です。

ここまでで C# 時刻処理の主要パターンと業務系本番事故ポイントは押さえた。SQL Server 連携の隣接トピックも貼っておきます。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次