はじめに
DateTimeはKindプロパティで「これはUTCかローカルか未指定か」を持ちますが、このKindは実行時に検証されず、シリアライズやDB保存の段階で暗黙的に解釈されます。開発機とサーバーのタイムゾーン設定が同じ場合は問題が表面化せず、海外リージョンへのデプロイやサマータイム地域とのやり取りで初めて日時のズレが発覚するのが典型パターンです。本記事ではDateTime/DateTimeOffsetまわりで実務上よく発生する3つの落とし穴を扱います。
前提知識 / 用語整理
-
DateTimeKind:DateTimeが持つUtc/Local/Unspecifiedのいずれかの属性。値そのものにタイムゾーン情報は含まれない -
DateTimeOffset: 日時に加えてUTCからのオフセット(例:+09:00)を保持する型。タイムゾーンをまたぐ比較・保存に向く -
DateTime.Now/DateTime.UtcNow: それぞれ実行環境のローカル時刻・UTC時刻を返す静的プロパティ -
ISO 8601:
2026-09-28T10:00:00+09:00のような日時の国際標準表記。DateTimeOffsetのデフォルト書式に近い
落とし穴1: DateTime.KindがUnspecifiedのままDBに保存し、タイムゾーンが失われる
再現コード
// ❌ Kindを意識せずDateTime.Nowをそのまま保存する
public class OrderEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
}
var order = new OrderEntity
{
Id = 1,
CreatedAt = DateTime.Now // ローカル時刻、Kind=Local
};
await _dbContext.SaveChangesAsync();
なぜ起きるか
多くのDBプロバイダ(datetime2型など)はタイムゾーン情報を保持せず、DateTimeの値部分だけを保存します。保存時にKindがLocalだったという情報はDBから読み戻すと失われ、既定でUnspecifiedとして復元されます。この状態でサーバーのタイムゾーン設定が変わったり、UTCで動くコンテナ環境に移行したりすると、「保存時はJST(+09:00)のつもりだった値」を、読み出し側がUTCとして誤解釈し、9時間のズレが生じます。開発機とサーバーのタイムゾーンが一致している間は再現しません。
正しい書き方
// ◎ 保存前にUTCへ統一する
var order = new OrderEntity
{
Id = 1,
CreatedAt = DateTime.UtcNow // Kind=Utc、常にUTCで統一
};
// ◎ 表示側でユーザーのタイムゾーンに変換する
var jstZone = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
var localTime = TimeZoneInfo.ConvertTimeFromUtc(order.CreatedAt, jstZone);
「保存はUTC、表示時にローカルへ変換」を一貫したルールにすることで、Kindの値に依存しない設計になります(ただし、TimeZoneInfo.FindSystemTimeZoneByIdのID文字列はWindowsとLinuxで異なる場合があるため、クロスプラットフォームで動かす場合はIANA形式のID(例: Asia/Tokyo)に対応した.NET 6以降のAPIを使う必要があります)。
落とし穴2: DateTime同士の比較でサマータイムを考慮せず、時間差の計算がずれる
再現コード
// ❌ ローカル時刻同士を単純に引き算する
var start = new DateTime(2026, 3, 8, 1, 30, 0, DateTimeKind.Local);
var end = new DateTime(2026, 3, 8, 3, 30, 0, DateTimeKind.Local);
var elapsed = end - start;
// サマータイム開始地域では、時計上は2時間でも実際の経過時間は1時間の場合がある
なぜ起きるか
DateTimeの減算は時計に表示される値同士の差を計算するだけで、その期間にサマータイムの切り替え(1時間の進み・戻り)が挟まっていても補正されません。サマータイムを採用する地域と時刻をやり取りするシステムでは、「時計上の経過時間」と「実際に経過した物理的な時間」がずれるケースがあり、課金期間の計算やSLAの経過時間計測で誤差として現れます。日本国内だけで完結するシステムでは(サマータイムがないため)気づかれにくく、海外拠点や海外ユーザー向け機能を追加した際に問題化します。
正しい書き方
// ◎ DateTimeOffsetで比較すれば、UTCオフセットを踏まえた実時間の差になる
var start = new DateTimeOffset(2026, 3, 8, 1, 30, 0, TimeSpan.FromHours(-5));
var end = new DateTimeOffset(2026, 3, 8, 3, 30, 0, TimeSpan.FromHours(-4)); // サマータイムでオフセットが変わる
var elapsed = end - start; // 実際に経過した物理的な時間として計算される
DateTimeOffsetは生成時点のUTCオフセットを値として保持するため、サマータイムの切り替えをオフセットの違いとして表現できます。時間差の計算や期限判定など「実際の経過時間」が重要な処理ではDateTimeではなくDateTimeOffsetを使うのが安全です。
落とし穴3: APIのJSONで日時のオフセットが抜け、クライアント側が誤解釈する
再現コード
public record EventDto(int Id, DateTime StartAt);
// ❌ Kind=UnspecifiedのDateTimeをそのままシリアライズ
var dto = new EventDto(1, new DateTime(2026, 9, 28, 10, 0, 0));
var json = JsonSerializer.Serialize(dto);
// => {"Id":1,"StartAt":"2026-09-28T10:00:00"} ※末尾にZやオフセットが付かない
なぜ起きるか
System.Text.JsonはDateTimeをKindに応じて書式化します。Kind=Utcなら末尾にZが付きますが、Kind=Unspecifiedの場合はオフセット情報のない文字列としてシリアライズされます。この文字列を受け取ったフロントエンド(JavaScriptのDateなど)は、オフセットがない日時文字列をブラウザのローカルタイムゾーンとして解釈することが多く、サーバー側の意図(UTCのつもりだった、JSTのつもりだった)とズレて表示される原因になります。
正しい書き方
// ◎ DTOの型自体をDateTimeOffsetにし、オフセットを明示的にJSONへ含める
public record EventDto(int Id, DateTimeOffset StartAt);
var dto = new EventDto(1, new DateTimeOffset(2026, 9, 28, 10, 0, 0, TimeSpan.Zero));
var json = JsonSerializer.Serialize(dto);
// => {"Id":1,"StartAt":"2026-09-28T10:00:00+00:00"}
API境界で日時をやり取りする場合は、DateTimeではなくDateTimeOffsetをDTOの型として採用すると、オフセット情報がJSONに含まれることが型レベルで保証されます(DateTimeのまま使う場合は、必ずKind=Utcに統一した上でシリアライズする運用ルールが必要です)。
ベストプラクティスまとめ
-
DB保存・API通信で扱う日時は
DateTime.UtcNowで統一し、表示時のみローカルタイムゾーンへ変換する -
サマータイムをまたぐ可能性がある地域との時間差計算には
DateTimeではなくDateTimeOffsetを使う -
API境界のDTOは
DateTimeではなくDateTimeOffsetを型として採用し、オフセットをJSONに含める -
クロスプラットフォームで動かす場合、タイムゾーンIDはIANA形式(
Asia/Tokyoなど)に対応したAPIを使う - 「日本国内のみ」を前提にした日時ロジックが海外拠点対応で通用するか、要件変更時に確認する
おわりに
DateTimeまわりの不具合は、開発環境と本番環境のタイムゾーン設定が一致している限り表面化せず、海外リージョンへの展開や海外ユーザーの追加といった要件変更のタイミングで一気に顕在化します。「保存はUTC、比較・期間計算はDateTimeOffset、API境界もDateTimeOffset」という3つの原則を徹底しておくと、多くのタイムゾーン関連の不具合を未然に防げます。