Contents

DateTime.Parse 解析文字時間時區會遺失問題

最近接到一個專案,原本時間欄位大多使用 DateTime,但因為要串另外一套 API,對方是使用 DateTimeOffset。我原本以為只要 JSON 裡面有時間字串,應該就會連時區一起帶著走,結果實際追問題時,還真的踩到時間少了 8 小時的坑。

後來往下追,問題點其實就卡在 DateTime.Parse 這種「把字串重新轉回時間」的流程。尤其當字串裡本來就沒有時區資訊時,這一步很容易把原本上下文中的時區概念弄丟。

心智圖

mindmap root((DateTime.Parse 時區遺失)) 問題場景 DateTime 與 DateTimeOffset 混用 API 串接 解析字串重新建時間 真正原因 字串沒有時區 Parse 後 Kind 常為 Unspecified 後續轉換時被當成本地或 UTC 常見症狀 少 8 小時 Json 序列化時間跑掉 比大小結果錯誤 解法 補上 offset 改用 DateTimeOffset.Parse 避免字串拼接做時間運算 最佳實務 API 優先用 DateTimeOffset 系統內統一 UTC 或明確時區 顯示時再轉時區

問題

我這次遇到的程式碼大概像下面這樣:

1
2
3
4
5
6
7
8
9
if (DateTime.Parse(day + " " + originStopDate.ArrivalTime) <= DateTimeEx.Now.AddMinutes(30))
{
		return new ServiceResponseBase<ReserverResponse>()
		{
				HasError = true,
				MessageCode = (int)AddBusReserveErrorCode.預約時間已過,
				Message = "預約時間已過"
		};
}

表面上看起來沒什麼問題,就是把日期跟時間字串拼起來再比較。但這裡最大的風險是:day + " " + originStopDate.ArrivalTime 這個字串本身沒有任何時區資訊。

也就是說,當你把它重新交給 DateTime.Parse 時,.NET 只知道「這是一個時間」,卻不知道它到底是哪一個時區的時間。

為什麼會發生?

先講結論:這類問題通常不是因為 DateTime.Parse 一定會直接變成 UTC,而是因為它在缺少時區資訊時,常常只會產生 Kind = DateTimeKind.UnspecifiedDateTime

1
2
var value = DateTime.Parse("2026-04-28 10:30:00");
Console.WriteLine(value.Kind); // Unspecified

KindUnspecified 時,後續如果你又拿它去做 JSON 序列化、ToUniversalTime()、和 DateTimeOffset 混算,或是交給別的系統處理,就很容易在某個環節被當成本地時間或 UTC,最後時間就偏掉了。

這裡最容易誤解

很多人會以為 DateTime.Parse("沒有時區的字串") 會直接變成 UTC。實際上,比較常見的情況是它回傳 Kind = Unspecified

真正出問題的,是你後面又把這個 Unspecified 拿去做跨系統、跨時區或序列化處理。

微軟文件也有提到,DateTime.Parse 在遇到包含 Z+08:00GMT 等時區資訊時,才會依條件解析成 LocalUtc;如果沒有,通常就是 Unspecified

原因整理

以下幾種狀況比較常見:

  1. 字串本身不含時區資訊,例如 2026-04-28 10:30:00
  2. 解析後得到 DateTimeKind.Unspecified
  3. 後面又和 DateTimeOffset、UTC 時間或 JSON 序列化混用。
  4. 系統在某個步驟自動幫你做時區換算,最後差了 8 小時。

參考文章: DateTime.Parse 與時區問題 - 黑暗執行緒

解法

方法 1:如果你真的只能從字串 parse,至少把 offset 補上

原本的寫法:

1
DateTime.Parse(day + " " + originStopDate.ArrivalTime)

這樣 parse 完之後,時間本身通常不帶明確時區資訊。

如果你很確定這筆資料就是台灣時間,至少可以把 offset 明確補上,再改用 DateTimeOffset.Parse

1
DateTimeOffset.Parse(day + " " + originStopDate.ArrivalTime + "+08:00")

如果只是想先做最小修改,這招通常能先把 bug 壓下來。

這是止血法,不是最理想作法
這種方式的前提是你非常確定來源時間就是 +08:00。如果系統未來可能支援不同時區,直接把字串硬補 +08:00 就不夠安全。

方法 2:直接用 DateTimeOffset,避免丟失 offset

如果資料本身本來就有時區概念,最穩的做法還是直接使用 DateTimeOffset

1
2
3
4
5
6
var reserveTime = DateTimeOffset.Parse("2026-04-28T10:30:00+08:00");

if (reserveTime <= DateTimeOffset.Now.AddMinutes(30))
{
		// 預約時間已過
}

DateTimeOffset 會把時間點和 offset 一起保留下來,對 API 串接、跨時區比較、資料交換都比較安全。

方法 3:不要把日期與時間先拼成字串再 parse

老實說,我自己不太喜歡用 Parse 來做時間運算,尤其是字串還是自己拼出來的時候。因為只要格式、文化設定、時區資訊有一個地方沒對齊,就很容易出 bug。

如果你手上其實有日期和時間的原始欄位,最好直接組成明確的時間物件。

例如你已經知道這是台灣時間:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var date = DateOnly.Parse(day);
var time = TimeOnly.Parse(originStopDate.ArrivalTime);

var reserveTime = new DateTimeOffset(
		date.Year,
		date.Month,
		date.Day,
		time.Hour,
		time.Minute,
		time.Second,
		TimeSpan.FromHours(8));

if (reserveTime <= DateTimeOffset.Now.AddMinutes(30))
{
		// 預約時間已過
}

這種寫法雖然比較長,但語意很明確,也比較不會因為字串格式問題踩雷。

補充:DateTime 和 DateTimeOffset 差在哪?

這個問題會反覆出現,很多時候不是 parse 本身,而是型別選錯。

DateTime

  • 只表示一個日期時間值。
  • 可以帶 Kind,但 Kind 只有 LocalUtcUnspecified 這幾種狀態。
  • 遇到跨系統交換資料時,很容易因為 Unspecified 產生誤解。

DateTimeOffset

  • 除了日期時間,還會保留 offset,例如 +08:00
  • 更適合 API、資料交換、跨時區系統。
  • 當你需要表達「某個實際時間點」時,比 DateTime 更安全。
我的實務建議

如果資料會跨 API、跨服務、跨地區流動,優先考慮 DateTimeOffset

如果只是系統內部單機使用,DateTime 也不是不能用,但至少要統一規則,例如全系統都用 UTC,顯示時再轉成本地時間。

一個簡單示範

下面這段程式碼可以很快看出差異:

1
2
3
4
5
6
7
8
var a = DateTime.Parse("2026-04-28 10:30:00");
var b = DateTimeOffset.Parse("2026-04-28T10:30:00+08:00");

Console.WriteLine(a);       // 2026/4/28 10:30:00
Console.WriteLine(a.Kind);  // Unspecified

Console.WriteLine(b);       // 2026/4/28 10:30:00 +08:00
Console.WriteLine(b.Offset); // 08:00:00

當你把 a 往後再做轉換時,就很容易出現系統各自解讀的問題;但 b 因為把 offset 一起保留下來,可預測性就高很多。

小結

這次踩到的坑,表面上像是 DateTime.Parse 壞掉了,但本質上其實是「沒有時區資訊的字串,被重新 parse 成時間後,又拿去跟有時區語意的資料混用」。

我自己最後的結論很簡單:

  1. 只要有跨系統、跨 API、跨時區需求,優先使用 DateTimeOffset
  2. 如果資料原本沒有時區,就不要假設 parse 完之後系統會幫你猜對。
  3. 能不用字串拼接做時間運算,就盡量不要用。

彩蛋