【C#】DateTime完全ガイド|DateTimeOffset・DateOnly・TimeOnly・タイムゾーン・書式変換・実践例まで

C# の DateTime は日付・時刻を扱う最も基本的な型ですが、「タイムゾーンの扱い方がわからない」「Parse が環境によって動作が変わる」「DateOnly と DateTime の使い分けがわからない」という疑問を持つ方も多いです。

本記事では DateTime の基礎から、DateTimeOffset・DateOnly・TimeOnly といったモダンな型、タイムゾーン変換、書式文字列一覧、年齢計算などの実践例まで体系的に解説します。

スポンサーリンク

日付・時刻を扱う型の全体マップ

C# には用途に応じた複数の日付・時刻型があります。最初に全体像を把握してから使う型を選びましょう。

意味 主な用途
DateTime ローカル or UTC の日付+時刻 最も汎用的。単一タイムゾーン内で使う場合に適切
DateTimeOffset UTC オフセット付きの日付+時刻 異なるタイムゾーン間で日時を比較・保存するなら必須
DateOnly 日付のみ(時刻なし) .NET 6+。誕生日・カレンダー日付など時刻が不要な場面
TimeOnly 時刻のみ(日付なし) .NET 6+。営業時間・アラーム時刻など日付が不要な場面
TimeSpan 時間の長さ(期間) 2つの DateTime の差・タイマー経過時間など
データベース・API など複数のタイムゾーンが絡む場合は最初から DateTimeOffset を選ぶのが安全です。DateTime は Kind プロパティ(Local/Utc/Unspecified)があいまいになりやすく、タイムゾーンのバグの温床になります。

DateTime の生成

DateTime の生成パターン一覧
// ─── 現在日時 ─────────────────────────────────────
DateTime local = DateTime.Now;       // ローカル日時(Kind=Local)
DateTime utc   = DateTime.UtcNow;   // UTC 日時(Kind=Utc)
DateTime today = DateTime.Today;    // 今日の日付(時刻は 0:00:00、Kind=Local)

// ─── 固定値 ──────────────────────────────────────
DateTime min = DateTime.MinValue;   // 0001/01/01 00:00:00
DateTime max = DateTime.MaxValue;   // 9999/12/31 23:59:59

// ─── コンストラクタ ────────────────────────────────
DateTime d1 = new DateTime(2025, 8, 28);              // 年月日
DateTime d2 = new DateTime(2025, 8, 28, 14, 30, 0);  // 年月日時分秒
DateTime d3 = new DateTime(2025, 8, 28, 14, 30, 0, 500); // ミリ秒付き

// ─── Kind を指定して生成 ──────────────────────────
DateTime local2 = new DateTime(2025, 8, 28, 9, 0, 0, DateTimeKind.Local);
DateTime utc2   = new DateTime(2025, 8, 28, 0, 0, 0, DateTimeKind.Utc);

// ─── Ticks から生成(100ns 単位)─────────────────
long ticks = DateTime.Now.Ticks;
DateTime fromTicks = new DateTime(ticks);

Kind プロパティ:タイムゾーンの落とし穴

DateTime には Kind プロパティがあり、3 種類の値を持ちます。この Kind の扱いを誤ると、タイムゾーン変換時に予期しない挙動になります。

Kind 値 生成方法 意味
DateTimeKind.Local DateTime.NowDateTime.Today ローカルタイムゾーンの時刻
DateTimeKind.Utc DateTime.UtcNow UTC の時刻
DateTimeKind.Unspecified コンストラクタのデフォルト タイムゾーン不明(要注意)
Kind の確認と変換
DateTime now = DateTime.Now;
Console.WriteLine(now.Kind);   // Local

DateTime utc = DateTime.UtcNow;
Console.WriteLine(utc.Kind);   // Utc

// コンストラクタのデフォルトは Unspecified
DateTime d = new DateTime(2025, 8, 28);
Console.WriteLine(d.Kind);     // Unspecified

// ─── Kind を指定して新しい DateTime を作る ────────
DateTime asUtc   = DateTime.SpecifyKind(d, DateTimeKind.Utc);
DateTime asLocal = DateTime.SpecifyKind(d, DateTimeKind.Local);

// ─── UTC ↔ Local の変換 ─────────────────────────
DateTime localTime = utc.ToLocalTime();   // UTC → ローカル
DateTime utcTime   = now.ToUniversalTime(); // ローカル → UTC
ToLocalTime()ToUniversalTime() は Kind が Unspecified のとき「Local として扱う」などの暗黙的な仮定が入り、想定外の変換になることがあります。複数のタイムゾーンが絡む場合は DateTimeOffset を使いましょう。

主要プロパティ

DateTime の主要プロパティ
DateTime dt = new DateTime(2025, 8, 28, 14, 30, 45, 123);

// ─── 日付・時刻の各要素 ──────────────────────────
Console.WriteLine(dt.Year);        // 2025
Console.WriteLine(dt.Month);       // 8
Console.WriteLine(dt.Day);         // 28
Console.WriteLine(dt.Hour);        // 14
Console.WriteLine(dt.Minute);      // 30
Console.WriteLine(dt.Second);      // 45
Console.WriteLine(dt.Millisecond); // 123

// ─── その他のプロパティ ──────────────────────────
Console.WriteLine(dt.DayOfWeek);   // Thursday(曜日)
Console.WriteLine(dt.DayOfYear);   // 240(その年の何日目か)
Console.WriteLine(dt.Ticks);       // 638,596,326,451,230,000(100ns 単位)

// ─── 日付部分・時刻部分のみ取得 ─────────────────
DateTime dateOnly = dt.Date;       // 2025/08/28 00:00:00(時刻を切り捨て)
TimeSpan timeOnly = dt.TimeOfDay;  // 14:30:45.123(時刻部分を TimeSpan で取得)
プロパティ 返す値の例 用途
DayOfWeek DayOfWeek.MondaySunday 曜日を enum で返す
DayOfYear 1 ~ 366 元日は 1、大晦日は 365 or 366
Ticks 0001/01/01 からの 100ns 単位カウント 高精度の時刻比較や記録に使える
Date 時刻部分を 0:00:00 に切り捨てた DateTime 日付のみの比較に便利
TimeOfDay TimeSpan で時刻部分を返す 時刻のみの比較に便利

日付・時刻の計算

Add 系メソッドで日付を加算・減算する

AddX メソッドによる日付計算
DateTime now = new DateTime(2025, 1, 31);

// ─── 加算(正の値)・減算(負の値)────────────────
Console.WriteLine(now.AddYears(1));    // 2026/01/31
Console.WriteLine(now.AddMonths(1));   // 2025/02/28 ← 2/31 は存在しないので末日に丸められる!
Console.WriteLine(now.AddDays(7));     // 2025/02/07
Console.WriteLine(now.AddHours(-2));   // 2025/01/30 22:00:00
Console.WriteLine(now.AddMinutes(90)); // 2025/01/31 01:30:00
Console.WriteLine(now.AddSeconds(-1)); // 2025/01/30 23:59:59
Console.WriteLine(now.AddMilliseconds(500));
Console.WriteLine(now.AddTicks(10_000_000)); // 1秒 = 10,000,000 ticks

// ─── AddTicks は精度が必要な場面で使う ────────────
DateTime start = DateTime.UtcNow;
// ... 処理 ...
DateTime end = DateTime.UtcNow;
TimeSpan elapsed = end - start; // 差分は TimeSpan で取れる
Console.WriteLine($"経過: {elapsed.TotalMilliseconds} ms");
AddMonths(1) は「1ヶ月後」を計算しますが、月末日の扱いに注意が必要です。1月31日の1ヶ月後は2月31日ではなく、2月の最終日(2月28日 or 29日)に自動的に丸められます。

TimeSpan:時間の長さを表す型

TimeSpan の基本操作
// ─── 2つの DateTime の差(TimeSpan を返す)────────
DateTime start = new DateTime(2025, 1, 1);
DateTime end   = new DateTime(2025, 12, 31);
TimeSpan diff  = end - start;

Console.WriteLine(diff.Days);          // 364
Console.WriteLine(diff.TotalHours);    // 8736.0
Console.WriteLine(diff.TotalMinutes);  // 524160.0

// ─── TimeSpan の生成 ─────────────────────────────
TimeSpan ts1 = new TimeSpan(2, 30, 0);   // 2時間30分0秒
TimeSpan ts2 = TimeSpan.FromHours(1.5);  // 1.5時間
TimeSpan ts3 = TimeSpan.FromDays(7);     // 7日間

// ─── DateTime と TimeSpan の演算 ─────────────────
DateTime deadline = DateTime.Now.Add(TimeSpan.FromDays(30)); // 30日後
DateTime yesterday = DateTime.Today - TimeSpan.FromDays(1);  // 昨日

// ─── 残り時間の計算 ──────────────────────────────
TimeSpan remaining = deadline - DateTime.Now;
Console.WriteLine($"残り {remaining.Days} 日 {remaining.Hours} 時間");

DateTime の比較

DateTime の比較パターン
DateTime d1 = new DateTime(2025, 8, 28);
DateTime d2 = new DateTime(2025, 9, 1);

// ─── 比較演算子(最も直感的)─────────────────────
bool isBefore = d1 < d2;   // true
bool isAfter  = d1 > d2;   // false
bool isEqual  = d1 == d2;  // false
bool isSameOrBefore = d1 <= d2; // true

// ─── CompareTo(ソートや三値比較に使う)──────────
int result = d1.CompareTo(d2); // -1(d1 < d2)
// 0 なら同じ、1 なら d1 > d2

// ─── DateTime.Compare 静的メソッド ────────────────
int cmp = DateTime.Compare(d1, d2); // -1

// ─── 同じ日付かどうか(時刻を無視して比較)────────
bool sameDay = d1.Date == d2.Date;

// ─── 期間内かどうかの判定 ────────────────────────
DateTime today = DateTime.Today;
DateTime periodStart = new DateTime(2025, 4, 1);
DateTime periodEnd   = new DateTime(2025, 6, 30);

bool isInPeriod = today >= periodStart && today <= periodEnd;
Console.WriteLine(isInPeriod ? "期間内" : "期間外");
DateTime は構造体(値型)のため、== 演算子で内容が同じかどうかを比較できます。ただし Kind(Local/Utc)が異なる場合でも比較できてしまう(内部の Ticks 値が等しければ true)ため、タイムゾーンをまたいだ比較には DateTimeOffset を使うべきです。

書式変換:ToString・Parse・TryParse

ToString でフォーマットを指定する

ToString の書式指定子
DateTime dt = new DateTime(2025, 8, 28, 14, 30, 45);

// ─── 標準書式(定義済みパターン)─────────────────
Console.WriteLine(dt.ToString("d"));   // 2025/08/28 (短い日付)
Console.WriteLine(dt.ToString("D"));   // 2025年8月28日 (長い日付)
Console.WriteLine(dt.ToString("t"));   // 14:30 (短い時刻)
Console.WriteLine(dt.ToString("T"));   // 14:30:45 (長い時刻)
Console.WriteLine(dt.ToString("f"));   // 2025年8月28日 14:30
Console.WriteLine(dt.ToString("F"));   // 2025年8月28日 14:30:45
Console.WriteLine(dt.ToString("g"));   // 2025/08/28 14:30
Console.WriteLine(dt.ToString("G"));   // 2025/08/28 14:30:45
Console.WriteLine(dt.ToString("s"));   // 2025-08-28T14:30:45 (ISO 8601)
Console.WriteLine(dt.ToString("o"));   // 2025-08-28T14:30:45.0000000 (ラウンドトリップ形式)
Console.WriteLine(dt.ToString("R"));   // Thu, 28 Aug 2025 14:30:45 GMT (HTTP 用)

// ─── カスタム書式 ─────────────────────────────────
Console.WriteLine(dt.ToString("yyyy/MM/dd"));           // 2025/08/28
Console.WriteLine(dt.ToString("yyyy年MM月dd日"));        // 2025年08月28日
Console.WriteLine(dt.ToString("HH:mm:ss"));             // 14:30:45
Console.WriteLine(dt.ToString("yyyy-MM-dd HH:mm:ss"));  // 2025-08-28 14:30:45
Console.WriteLine(dt.ToString("M月d日(ddd)"));           // 8月28日(木)

// ─── 文字列補間でも使える ─────────────────────────
Console.WriteLine($"{dt:yyyy/MM/dd}");     // 2025/08/28
Console.WriteLine($"{dt:HH時mm分ss秒}");   // 14時30分45秒
書式指定子 意味 例(2025/8/28 14:30:45)
yyyy 年(4桁) 2025
yy 年(2桁) 25
MM 月(2桁・ゼロ埋め) 08
M 月(ゼロ埋めなし) 8
dd 日(2桁・ゼロ埋め) 28
d 日(ゼロ埋めなし) 28
HH 時(24時間・2桁) 14
hh 時(12時間・2桁) 02
mm 分(2桁) 30
ss 秒(2桁) 45
fff ミリ秒(3桁) 000
ddd 曜日(省略形)
dddd 曜日(フル) 木曜日
tt 午前/午後 午後
K タイムゾーン(Kind に応じて) +09:00 or Z

Parse・TryParse:文字列から DateTime へ

Parse・TryParse・ParseExact の使い分け
// ─── Parse: パース失敗すると例外 ─────────────────
DateTime d1 = DateTime.Parse("2025/08/28");
DateTime d2 = DateTime.Parse("2025-08-28T14:30:45");

// ─── TryParse: 失敗しても例外が出ない(安全)─────
string input = "2025/08/28";
if (DateTime.TryParse(input, out DateTime result))
    Console.WriteLine($"パース成功: {result}");
else
    Console.WriteLine("パース失敗");

// ─── ParseExact: 書式を厳密に指定 ────────────────
// ユーザー入力・外部ファイルの日付文字列を正確にパース
DateTime exact = DateTime.ParseExact(
    "28-08-2025 14:30",
    "dd-MM-yyyy HH:mm",
    System.Globalization.CultureInfo.InvariantCulture
);
Console.WriteLine(exact); // 2025/08/28 14:30:00

// ─── TryParseExact: ParseExact の安全版 ──────────
string csvDate = "20250828";
bool ok = DateTime.TryParseExact(
    csvDate,
    "yyyyMMdd",
    System.Globalization.CultureInfo.InvariantCulture,
    System.Globalization.DateTimeStyles.None,
    out DateTime parsed
);
if (ok) Console.WriteLine($"CSV日付: {parsed:yyyy/MM/dd}");

// ─── 複数の書式を一度に試す ───────────────────────
string[] formats = { "yyyy/MM/dd", "yyyy-MM-dd", "yyyyMMdd" };
bool ok2 = DateTime.TryParseExact(
    "2025-08-28", formats,
    System.Globalization.CultureInfo.InvariantCulture,
    System.Globalization.DateTimeStyles.None,
    out DateTime multi
);
DateTime.Parse("8/28/2025") は実行環境のカルチャ設定によって月と日が逆に解釈されることがあります(米国環境では Month/Day、日本環境では年/月/日)。外部入力のパースには必ず ParseExactTryParseExactCultureInfo.InvariantCulture を指定しましょう。

静的ユーティリティメソッド

IsLeapYear・DaysInMonth・SpecifyKind
// ─── うるう年の判定 ──────────────────────────────
Console.WriteLine(DateTime.IsLeapYear(2024)); // true
Console.WriteLine(DateTime.IsLeapYear(2025)); // false

// ─── 特定の月の日数を取得 ────────────────────────
Console.WriteLine(DateTime.DaysInMonth(2024, 2)); // 29(うるう年)
Console.WriteLine(DateTime.DaysInMonth(2025, 2)); // 28
Console.WriteLine(DateTime.DaysInMonth(2025, 4)); // 30

// 月の最終日を取得する実用的なパターン
DateTime lastDay = new DateTime(2025, 2, DateTime.DaysInMonth(2025, 2));
Console.WriteLine(lastDay); // 2025/02/28

// ─── Kind を変更して新しい DateTime を作る ────────
DateTime parsed = new DateTime(2025, 8, 28); // Kind=Unspecified
DateTime asUtc  = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
Console.WriteLine(asUtc.Kind); // Utc

DateTimeOffset:タイムゾーンを正確に扱う

DateTimeOffsetDateTime に UTC オフセット(+09:00 など)を付加した型です。タイムゾーンが異なる日時を正確に比較・変換できます。

DateTimeOffset の基本
// ─── 現在日時(ローカルオフセット付き)──────────
DateTimeOffset now  = DateTimeOffset.Now;    // 2025-08-28T14:30:45+09:00
DateTimeOffset utc  = DateTimeOffset.UtcNow; // 2025-08-28T05:30:45+00:00

Console.WriteLine(now.Offset);         // +09:00:00(現在のUTCオフセット)
Console.WriteLine(now.UtcDateTime);    // UTC に変換した DateTime を返す

// ─── 固定オフセットを指定して生成 ────────────────
var dto = new DateTimeOffset(2025, 8, 28, 14, 30, 0, TimeSpan.FromHours(9));
Console.WriteLine(dto); // 2025-08-28T14:30:00+09:00

// ─── DateTime から変換 ────────────────────────────
DateTime local = DateTime.Now;
DateTimeOffset fromLocal = new DateTimeOffset(local); // Kind=Local のオフセットを継承

// ─── タイムゾーンをまたいだ比較 ─────────────────
var tokyo   = new DateTimeOffset(2025, 8, 28, 14, 0, 0, TimeSpan.FromHours(9));
var newYork = new DateTimeOffset(2025, 8, 28,  1, 0, 0, TimeSpan.FromHours(-4));
// UTC に正規化すると両者ともに 2025-08-28T05:00:00Z → 同じ瞬間として true
Console.WriteLine(tokyo == newYork); // true(UTC で一致)

// DateTimeOffset.Now と .UtcNow は取得タイミングが微妙にずれるため == は不確実
// 同じ値を変換して比較するのが確実
DateTimeOffset t = DateTimeOffset.Now;
Console.WriteLine(t == t.ToUniversalTime()); // true(同じ瞬間を UTC に変換)

// ─── ISO 8601 文字列の変換 ────────────────────────
DateTimeOffset parsed = DateTimeOffset.Parse("2025-08-28T14:30:00+09:00");
string iso8601 = parsed.ToString("o"); // 2025-08-28T14:30:00.0000000+09:00
データベースや REST API で日時を保存・交換する場合は DateTimeOffset を使い、ISO 8601 形式("o" 書式)で文字列化すると相互運用性が高まります。UTC で保存し表示時にローカル変換するのがベストプラクティスです。

DateOnly・TimeOnly:.NET 6 以降の新型

.NET 6 で導入された DateOnlyTimeOnly は「日付だけ」「時刻だけ」を表す専用型です。DateTime に時刻 0:00:00 を付けて日付を表す必要がなくなります。

DateOnly・TimeOnly の基本
// ─── DateOnly: 日付のみ ──────────────────────────
DateOnly today = DateOnly.FromDateTime(DateTime.Today);
DateOnly birthday = new DateOnly(1990, 4, 15);

Console.WriteLine(today);              // 2025-08-28
Console.WriteLine(birthday.Year);     // 1990
Console.WriteLine(birthday.DayOfWeek); // Sunday

DateOnly nextMonth = today.AddMonths(1);
int span = today.DayNumber - birthday.DayNumber; // 日数差

// ─── TimeOnly: 時刻のみ ──────────────────────────
TimeOnly openTime  = new TimeOnly(9, 0, 0);   // 09:00:00
TimeOnly closeTime = new TimeOnly(18, 30, 0); // 18:30:00
TimeOnly now2 = TimeOnly.FromDateTime(DateTime.Now);

// 営業時間内かどうかの判定
bool isOpen = now2.IsBetween(openTime, closeTime);
Console.WriteLine(isOpen ? "営業中" : "営業時間外");

Console.WriteLine(openTime.ToString("HH:mm"));   // 09:00
Console.WriteLine(closeTime.ToString("h:mm tt")); // 6:30 PM

// ─── DateOnly × TimeOnly → DateTime に変換 ──────
DateTime dt = today.ToDateTime(now2); // DateOnly + TimeOnly → DateTime
ユースケース 推奨型 理由
誕生日・期日・カレンダー DateOnly 時刻が不要な概念には DateTime の 0:00:00 より意味が明確
営業時間・アラーム時刻 TimeOnly 日付を持たない時刻の概念に最適
ログの正確な記録・API DateTimeOffset タイムゾーンを含む完全な日時
単一タイムゾーン内の処理 DateTime 従来どおり使える。ただし Kind に注意

TimeZoneInfo:タイムゾーン変換

TimeZoneInfo でタイムゾーンを変換する
// ─── タイムゾーンを指定して変換 ──────────────────
// Windows では "Tokyo Standard Time"、Linux/macOS では "Asia/Tokyo"
TimeZoneInfo jst = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
TimeZoneInfo pst = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

DateTime utcNow = DateTime.UtcNow;

// UTC → 東京時間
DateTime tokyoTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, jst);
Console.WriteLine($"東京: {tokyoTime:yyyy/MM/dd HH:mm:ss}");

// UTC → 太平洋標準時
DateTime pstTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, pst);
Console.WriteLine($"PST: {pstTime:yyyy/MM/dd HH:mm:ss}");

// ─── クロスプラットフォーム対応 ─────────────────
// Windows と Linux/macOS でタイムゾーン ID が異なる問題の解決策
// NuGet: TimeZoneConverter パッケージを使うと両環境で同じコードが書ける
// TimeZoneInfo jst2 = TZConvert.GetTimeZoneInfo("Asia/Tokyo"); // TimeZoneConverter

// ─── 夏時間を考慮した判定 ─────────────────────────
bool isDST = pst.IsDaylightSavingTime(pstTime);
Console.WriteLine($"夏時間適用中: {isDST}");

Console.WriteLine($"UTCオフセット: {pst.GetUtcOffset(pstTime)}");

// ─── 全タイムゾーン一覧 ──────────────────────────
foreach (var tz in TimeZoneInfo.GetSystemTimeZones())
    Console.WriteLine($"{tz.Id}: {tz.DisplayName}");

実践例

年齢を計算する

誕生日から現在の年齢を正確に計算する
static int CalculateAge(DateOnly birthDate, DateOnly today)
{
    int age = today.Year - birthDate.Year;
    // まだ誕生日が来ていない場合は 1 引く
    if (today < birthDate.AddYears(age))
        age--;
    return age;
}

// 使い方
DateOnly birthday = new DateOnly(1990, 2, 28);
DateOnly today    = DateOnly.FromDateTime(DateTime.Today);
Console.WriteLine($"年齢: {CalculateAge(birthday, today)} 歳");

// うるう年生まれ(2/29)への対応
// DateOnly.AddYears は 2/29 → 2/28 に丸めるため正しく動作する
DateOnly leapBirth = new DateOnly(2000, 2, 29);
Console.WriteLine(CalculateAge(leapBirth, today));

期限判定と残り日数

タスクの期限チェックと残り日数の計算
record TaskItem(string Name, DateTime Deadline);

static string GetDeadlineStatus(TaskItem task)
{
    DateTime now = DateTime.Now;
    TimeSpan remaining = task.Deadline - now;

    if (remaining.TotalDays < 0)
        return $"[期限超過] {task.Name} ({Math.Abs((int)remaining.TotalDays)} 日前に期限切れ)";
    if (remaining.TotalDays < 1)
        return $"[今日締切] {task.Name} (残り {(int)remaining.TotalHours} 時間)";
    if (remaining.TotalDays < 7)
        return $"[警告] {task.Name} (残り {(int)remaining.TotalDays} 日)";

    return $"[正常] {task.Name} (期限: {task.Deadline:yyyy/MM/dd})";
}

// 使い方
var tasks = new[]
{
    new TaskItem("設計書作成", DateTime.Today.AddDays(-1)),    // 昨日が期限
    new TaskItem("コードレビュー", DateTime.Today.AddHours(3)), // 今日
    new TaskItem("テスト実施", DateTime.Today.AddDays(5)),      // 5日後
    new TaskItem("リリース", DateTime.Today.AddDays(30)),       // 30日後
};

foreach (var task in tasks)
    Console.WriteLine(GetDeadlineStatus(task));

月の営業日を列挙する

指定した年月の平日(月〜金)をすべて取得する
static IEnumerable<DateOnly> GetBusinessDays(int year, int month)
{
    int daysInMonth = DateTime.DaysInMonth(year, month);
    for (int day = 1; day <= daysInMonth; day++)
    {
        var date = new DateOnly(year, month, day);
        if (date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday)
            yield return date;
    }
}

// 使い方
var workdays = GetBusinessDays(2025, 8).ToList();
Console.WriteLine($"2025年8月の営業日数: {workdays.Count} 日");
Console.WriteLine($"最初の営業日: {workdays.First()}");
Console.WriteLine($"最後の営業日: {workdays.Last()}");

よくある落とし穴と注意点

DateTime.Now をテストで使うとテストが不安定になる

DateTime.Now を直接呼び出すコードはユニットテストで問題になります。テスト実行タイミングによって結果が変わるためです。現在時刻を取得する処理は Func<DateTime> clockIClock インターフェースを引数で受け取るよう設計し、テスト時はモックに差し替えられるようにしましょう。

Kind の異なる DateTime を比較するとバグになる

KindのミスマッチによるバグとDateTimeOffsetでの解決
// BAD: ToUniversalTime() で変換しても Kind が違うため Ticks が一致せず == は false
DateTime someTime   = new DateTime(2025, 8, 28, 14, 0, 0, DateTimeKind.Local);
DateTime utcVersion = someTime.ToUniversalTime(); // Kind=Utc、例: 05:00 UTC
bool wrongResult    = someTime == utcVersion;     // false(14:00 と 05:00 の Ticks が違う)

// GOOD: DateTimeOffset は UTC に正規化してから比較するため正しく一致する
DateTimeOffset dtoLocal = new DateTimeOffset(someTime);
DateTimeOffset dtoUtc   = dtoLocal.ToUniversalTime();
bool correct = dtoLocal == dtoUtc; // true(同じ瞬間として正しく比較)

AddMonths は月末日を自動丸める

1月31日の1ヶ月後を求める際に AddMonths(1) を使うと、自動的に2月の最終日(28日 or 29日)に丸められます。「毎月末日に繰り返す」処理を実装する場合は DateTime.DaysInMonth(year, month) で明示的に末日を求める必要があります。

Parse はカルチャに依存する

DateTime.Parse("04/08/2025") は日本語環境では「4月8日」ですが、英語(米国)環境では「8月4日」と解釈されます。外部から受け取った日付文字列は必ず ParseExactCultureInfo.InvariantCulture を指定して書式を固定してください。

よくある質問

QDateTime と DateTimeOffset はどちらを使うべきですか?
A単一タイムゾーン内(サーバーもクライアントも同じ国内)で使うなら DateTime(UtcNow を使って UTC で統一)が十分です。複数のタイムゾーンをまたぐ可能性がある場合(グローバルサービス・データベース保存・API 連携)は DateTimeOffset を使うと安全です。新規プロジェクトでは最初から DateTimeOffset を選ぶのが無難です。
QDateTime.Now と DateTime.UtcNow の違いは何ですか?
ADateTime.Now は実行マシンのローカルタイムゾーンの現在時刻(Kind=Local)を返します。DateTime.UtcNow は UTC 時刻(Kind=Utc)を返します。ログ・データベース保存には UTC を使うのが一般的です。UtcNow の方が Now より若干高速(ローカル変換が不要)という側面もあります。
QDateOnly は .NET Framework でも使えますか?
ADateOnlyTimeOnly は .NET 6 以降のみで利用できます。.NET Framework では利用できません。.NET Framework では DateTime.Date で日付部分を取り出すか、new DateTime(year, month, day) を使ってください。
Q日付の差分(日数)を計算するにはどうすればいいですか?
ADateTime 同士を引き算すると TimeSpan が返ります。(d2 - d1).Days で整数の日数差が取れます。DateOnly の場合は d2.DayNumber - d1.DayNumber で日数差を計算できます。時刻を無視して純粋に日数を数えたい場合は d2.Date - d1.Date として時刻部分を切り捨ててから引き算するか、DateOnly を使いましょう。
Qタイムゾーン変換で Windows と Linux の動作が異なります。
ATimeZoneInfo.FindSystemTimeZoneById で指定するタイムゾーン ID が Windows("Tokyo Standard Time")と Linux/macOS("Asia/Tokyo")で異なります。NuGet の TimeZoneConverter パッケージを使うと、IANA 形式("Asia/Tokyo")で統一して両プラットフォームで動作するコードが書けます。

まとめ

内容 ポイント
型の選択 日付のみ → DateOnly、時刻のみ → TimeOnly、タイムゾーン跨ぎ → DateTimeOffset
Kind プロパティ Unspecified は要注意。UTC で統一して Local/Utc を明示する
日付計算 AddX メソッドで加減算。月末日は DaysInMonth で確認
書式変換 ToString("yyyy/MM/dd")、文字列補間 $"{dt:HH:mm}"
パース 外部入力は TryParseExact + InvariantCulture で書式を固定
タイムゾーン TimeZoneInfo.ConvertTimeFromUtc で変換。クロスプラットフォームは TimeZoneConverter
テスト DateTime.Now を直接使わず、時刻取得を DI で差し替え可能にする

DateTime で生成した文字列を加工する場面では文字列操作完全ガイド、書式文字列を文字列補間と組み合わせる方法は文字列補間($”…”)の基本と活用例も参考にしてください。