みなさんこんにちは!ヒロポンです!!
今回は業務系の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)
ポイント:
TimeZoneInfo.FindSystemTimeZoneByIdのIDはOS依存(Windows: "Tokyo Standard Time"、Linux/Mac: "Asia/Tokyo")- DST(サマータイム)は自動で考慮される(EST/EDTの切り替えが内部で走る)
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.UtcとDateFormatHandling.IsoDateFormatをシリアライザ設定に入れておくのが安全。
JSON連携で一番踏みやすいのは「送り側はUTCのつもりだったのに、Kind=UnspecifiedのままJSONにしてオフセット情報が消えて、受け側でローカル時刻と解釈される」事故。送り側でシリアライズ前にKindをUtcに固定するか、DateTimeOffset型で持つのが本命です。
ハマりポイント—業務系の本番事故あるある
ここからは実際に俺が踏んだやつを3つ。流通系SIer業務系で時刻周りに手を入れる時のチェックリスト代わりにどうぞ。
1.受発注ログ1時間ズレ事件(半日デバッガで追ってハマった)
SQL ServerからDataReaderで読み出したcreated_atをToUniversalTime()で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行ルールにまとめた:
- サーバー側時刻は
DateTime.UtcNow寄せ(新規DateTime.Nowは禁止、UI表示だけローカル変換) - SQL Server読み出し直後に
DateTime.SpecifyKindでKind確定(ラッパー関数経由を推奨) - 新規テーブルは
datetime2、JSONシリアライズはDateTimeZoneHandling.Utcで固定
このルール化で、時刻ズレ事故が消えた。書き方を1パターンに揃えるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。
C# 7.3 + .NET Framework 4.7.2 + SQL Server 2016のレガシー業務系って、DateTimeOffsetもdatetime2もTimeZoneInfoも普通に使える環境なのに、書き方がDateTime.Now + datetime時代から進化してないコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。
まとめ
| 状況 | 推奨パターン |
|---|---|
| サーバー側時刻ログ・DB保存 | DateTime.UtcNow(Kind=Utc固定) |
| UI表示のローカル時刻 | utcDt.ToLocalTime()でUTCから変換 |
| 複数タイムゾーン跨ぎ | DateTimeOffset寄せ(オフセット込み) |
| 明示的タイムゾーン変換 | TimeZoneInfo.ConvertTime(DST自動考慮) |
| SQL Server新規テーブル | datetime2(datetimeは等価比較事故の温床) |
| SQL Server読み出し直後 | DateTime.SpecifyKind(dt, DateTimeKind.Utc) |
| JSONシリアライズ | DateTimeZoneHandling.Utc + ISO 8601形式 |
時刻処理の事故は、「Kindを確定させる」「タイムゾーン情報を持ち回る」「精度を揃える」の3点で9割消えます。DateTime.NowをDateTime.UtcNowに置き換えるだけで、サーバー移転やクラウド移行で踏む時刻ズレ事故の大半は予防できる。業務系の時刻周りは書き方を1パターンに揃えるのが本命の対処です。
よくある質問
Q1. DateTime.NowとDateTime.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のdatetimeとdatetime2はどっちを使うべき?
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連携の隣接トピックも貼っておきます。
関連記事
- C# DataAdapter.Update()でDBNull例外が出た時の最短対処 — DataAdapterでdatetimeカラムのDBNullを扱う時に効く
- VB.netのRight / Mid / LeftをC#に翻訳する完全早見表 —レガシー業務系で日付文字列を切り出す処理をC#化する時に効く
- C#でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet) —時刻ログをDistinctで集約する時に効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!
執筆者
バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


コメント