Mert Özen Her Satırda Daha İleri
Backend C# Temelleri — #16

C# Tarih ve Saat: DateTime, DateTimeOffset, DateOnly, TimeOnly ve UTC

Mert Özen 7 Kas 2026 9 dk 1 görüntülenme
C# Tarih ve Saat: DateTime, DateTimeOffset, DateOnly, TimeOnly ve UTC

Neden aynı rakamlı UTC ve yerel DateTime eşit çıkıyor? Kind sorunu, anı sabitleyen DateTimeOffset, TimeZoneInfo ve yaz saati, .NET 6 ile gelen DateOnly ve TimeOnly, TimeSpan'de Days ile TotalDays farkı, culture'a bağlı biçimlendirme ve parse, UTC'de saklama kuralı.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Bu kodu İstanbul saatine (UTC+3) ayarlı bir makinede çalıştırıyorum. İki DateTime aynı rakamlarla kuruluyor, ama biri UTC, diğeri yerel saat olarak işaretli. İstanbul'da sabah 9 ile Londra'da sabah 9 arasında üç saat var. Karşılaştırma ne yazdırır?

DateTime utc = new DateTime(2026, 11, 7, 9, 0, 0, DateTimeKind.Utc);
DateTime local = new DateTime(2026, 11, 7, 9, 0, 0, DateTimeKind.Local);

Console.WriteLine(utc == local);

DateTime'ın Kind Sorunu

Çıktı True. İki değer zamanda üç saat uzakta, ama DateTime onları eşit sayıyor. Sebebi, DateTime'ın içinde iki ayrı bilgi tutması: tarih ve saatin tick sayısı ve bu değerin ne anlama geldiğini söyleyen Kind. Karşılaştırma operatörleri sadece tick'lere bakar, Kind'ı hiç hesaba katmaz.

Kind üç değer alabilir: Utc, Local ve Unspecified. Farkı en iyi round-trip biçimi olan "o" gösterir. CultureInfo implicit using'ler arasında olmadığı için bu yazıdaki örneklerin en başına using System.Globalization; satırını eklemen gerekiyor:

CultureInfo inv = CultureInfo.InvariantCulture;
DateTime unspecified = new DateTime(2026, 11, 7, 9, 0, 0);

Console.WriteLine(utc.ToString("o", inv));         // 2026-11-07T09:00:00.0000000Z
Console.WriteLine(local.ToString("o", inv));       // 2026-11-07T09:00:00.0000000+03:00
Console.WriteLine(unspecified.ToString("o", inv)); // 2026-11-07T09:00:00.0000000
Console.WriteLine(unspecified.Kind);               // Unspecified

Console.WriteLine(local.ToUniversalTime().ToString("o", inv));       // 2026-11-07T06:00:00.0000000Z
Console.WriteLine(unspecified.ToUniversalTime().ToString("o", inv)); // 2026-11-07T06:00:00.0000000Z
Console.WriteLine(utc == local.ToUniversalTime());                   // False

Sondaki Z "bu UTC" demek, +03:00 yerel saatin farkını gösteriyor. Hiçbir şey yazmayan satır Unspecified: "bu bir tarih ve saat, ama nereye ait olduğunu bilmiyorum". Kind vermeden kurduğun her DateTime bu türdendir ve en sinsi olanı da odur. ToUniversalTime() çağırdığında Unspecified bir değeri yerel saat kabul eder; aynı kod Frankfurt'taki bir sunucuda başka, senin makinende başka sonuç verir.

Doğru karşılaştırma için iki değeri önce aynı türe getirmen gerekiyor. Son satırda yerel değeri UTC'ye çevirince aradaki üç saat ortaya çıktı ve sonuç False oldu. Kısacası DateTime kendi başına "hangi an?" sorusuna güvenilir bir cevap vermez; o cevabı Kind'ı doğru tutarak senin vermen gerekir.

DateTimeOffset: Anı Sabitlemek

DateTimeOffset, tarih ve saatin yanında UTC'den farkı da taşır. Böylece değer kendi başına zaman çizgisindeki tek bir anı gösterir:

DateTimeOffset istanbul = new DateTimeOffset(2026, 11, 7, 12, 0, 0, TimeSpan.FromHours(3));
DateTimeOffset london = new DateTimeOffset(2026, 11, 7, 9, 0, 0, TimeSpan.Zero);

Console.WriteLine(istanbul == london);           // True: aynı an
Console.WriteLine(istanbul.EqualsExact(london)); // False: offset'ler farklı
Console.WriteLine(istanbul.UtcDateTime.ToString("o", inv)); // 2026-11-07T09:00:00.0000000Z
Console.WriteLine(istanbul.ToString("o", inv));  // 2026-11-07T12:00:00.0000000+03:00

Bu sefer eşitlik doğru soruyu soruyor: İstanbul'da öğlen 12 ile Londra'da sabah 9 aynı an. Bir sipariş zamanını, bir log satırını, bir API cevabındaki zaman damgasını DateTimeOffset olarak tutarsan, değer hangi makineye giderse gitsin anlamını kaybetmez. Bir Web API'nin JSON ile döndüğü zaman damgaları için de en güvenli seçim budur.

Offset ile zaman dilimi aynı şey değil. +03:00 bir anın farkını söyler, "İstanbul" ise kuralları olan bir bölgedir. Yaz saati uygulayan bir şehirde offset yıl içinde değişir. Bir anı belli bir şehrin saatiyle göstermek istediğinde TimeZoneInfo kullanırsın:

DateTime orderedUtc = new DateTime(2026, 11, 7, 9, 0, 0, DateTimeKind.Utc);
DateTime summerUtc = new DateTime(2026, 7, 7, 9, 0, 0, DateTimeKind.Utc);
TimeZoneInfo istanbulZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
TimeZoneInfo newYorkZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");

Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(orderedUtc, istanbulZone).ToString("yyyy-MM-dd HH:mm", inv)); // 2026-11-07 12:00
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(orderedUtc, newYorkZone).ToString("yyyy-MM-dd HH:mm", inv));  // 2026-11-07 04:00
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(summerUtc, newYorkZone).ToString("yyyy-MM-dd HH:mm", inv));   // 2026-07-07 05:00
Console.WriteLine(newYorkZone.GetUtcOffset(orderedUtc) + " " + newYorkZone.GetUtcOffset(summerUtc));           // -05:00:00 -04:00:00

New York'un farkı kasımda -5, temmuzda -4 saat; yaz saatini TimeZoneInfo senin yerine hesaba kattı. "Europe/Istanbul" bir IANA kimliği; macOS ve Linux bunları doğrudan tanır. .NET 6 ile Windows'ta da IANA kimlikleri kabul edilmeye başladı, ama eski projelerde "Turkey Standard Time" gibi Windows kimlikleriyle de karşılaşırsın.

DateOnly ve TimeOnly

Her tarih bir an değildir. Bir kitabın yayın tarihi, bir müşterinin doğum günü, iade için son gün: bunların saati de zaman dilimi de yoktur. Eskiden bunları gece yarısına ayarlanmış bir DateTime ile tutardık ve bir saat dilimi dönüşümü tarihi bir gün geriye kaydırınca şaşırırdık. .NET 6 bu boşluğu iki yeni tiple kapattı:

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

DateOnly published = new DateOnly(1949, 6, 8);
Console.WriteLine(published);                       // 06/08/1949
Console.WriteLine(published.DayOfWeek);             // Wednesday
Console.WriteLine(published.ToString("yyyy-MM-dd")); // 1949-06-08

TimeOnly opens = new TimeOnly(9, 30);
TimeOnly closes = new TimeOnly(21, 0);
TimeOnly lateVisit = new TimeOnly(22, 15);

Console.WriteLine(lateVisit.IsBetween(opens, closes));                            // False
Console.WriteLine(lateVisit.IsBetween(new TimeOnly(22, 0), new TimeOnly(6, 0))); // True
Console.WriteLine(new TimeOnly(23, 30).AddHours(2));                              // 01:30

DateTime placedAt = new DateTime(2026, 11, 7, 14, 45, 0);
Console.WriteLine(DateOnly.FromDateTime(placedAt));        // 11/07/2026
Console.WriteLine(TimeOnly.FromDateTime(placedAt));        // 14:45
Console.WriteLine(new DateOnly(2026, 1, 31).AddMonths(1)); // 02/28/2026

İlk satırda çıktının culture'dan bağımsız olması için mevcut culture'ı invariant'a çektim; birazdan culture konusuna geleceğiz. DateOnly sadece gün, ay ve yıl tutar. TimeOnly ise günün saatini tutar ve 24 saatin etrafında döner: 23:30'a iki saat ekleyince 01:30 olur. IsBetween gece yarısını aşan aralıkları da anlıyor; 22:00 ile 06:00 arasındaki gece vardiyasını tek bir çağrıyla kontrol edebiliyorsun. Başlangıç dahil, bitiş hariçtir.

AddMonths ayın son gününde dikkat ister: 31 Ocak'a bir ay eklemek 28 Şubat verir, çünkü 31 Şubat yok. Aynı davranış DateTime.AddMonths'ta da var. Aylık abonelik gibi bir şey hesaplıyorsan her ayı bir öncekine ekleyerek değil, başlangıç tarihine ekleyerek bul; yoksa 28'e kayan gün bir daha 31'e dönmez.

Bir not: .NET 6'daki System.Text.Json DateOnly ve TimeOnly'yi kendiliğinden serialize etmez, bu destek .NET 7'de geldi. .NET 6 hedefleyen bir API'de bu tipleri kullanacaksan kendi converter'ını yazman gerekir.

TimeSpan ve Tarih Aritmetiği

İki anın farkı bir TimeSpan'dir. Siparişin ne kadar sürede teslim edildiğini hesaplayalım:

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

DateTime ordered = new DateTime(2026, 10, 30, 18, 0, 0, DateTimeKind.Utc);
DateTime delivered = new DateTime(2026, 11, 2, 10, 30, 0, DateTimeKind.Utc);

TimeSpan took = delivered - ordered;
Console.WriteLine(took);            // 2.16:30:00
Console.WriteLine(took.Days);       // 2
Console.WriteLine(took.Hours);      // 16
Console.WriteLine(took.TotalDays);  // 2.6875
Console.WriteLine(took.TotalHours); // 64.5

DateOnly returnDeadline = DateOnly.FromDateTime(delivered).AddDays(14);
Console.WriteLine(returnDeadline.ToString("yyyy-MM-dd")); // 2026-11-16

Console.WriteLine(TimeSpan.FromMinutes(90)); // 01:30:00
Console.WriteLine(new DateOnly(2026, 12, 25).DayNumber - new DateOnly(2026, 11, 7).DayNumber); // 48

Days ile TotalDays arasındaki fark çok kişiyi yakar. Days, "2 gün 16 saat 30 dakika" ifadesindeki gün parçasıdır; Hours da saat parçası. TotalDays ise sürenin tamamının gün cinsinden karşılığıdır. "Teslimat 48 saatten uzun sürdü mü?" sorusunu took.Hours > 48 diye yazarsan hiçbir zaman doğru çıkmaz, çünkü Hours en fazla 23 olur. Doğrusu took.TotalHours > 48 ya da took > TimeSpan.FromHours(48).

İki DateOnly arasında doğrudan çıkarma operatörü yok. Gün farkı için DayNumber property'lerini çıkarırsın; 25 Aralık'taki kampanyaya 48 gün kaldığını böyle bulduk.

Biçimlendirme ve Culture

Bir tarihin ekranda nasıl yazılacağı culture'a bağlıdır. String'ler ve tip dönüşümleri yazılarında sayılar için gördüğümüz kural burada da geçerli: culture'ı her zaman açıkça ver.

CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");
CultureInfo us = CultureInfo.GetCultureInfo("en-US");
CultureInfo inv = CultureInfo.InvariantCulture;

DateTime when = new DateTime(2026, 11, 7, 14, 5, 0);

Console.WriteLine(when.ToString("D", tr)); // 7 Kasım 2026 Cumartesi
Console.WriteLine(when.ToString("D", us)); // Saturday, November 7, 2026
Console.WriteLine(when.ToString("g", tr)); // 7.11.2026 14:05
Console.WriteLine(when.ToString("g", us)); // 11/7/2026 2:05 PM
Console.WriteLine(when.ToString("dd.MM.yyyy HH:mm", inv)); // 07.11.2026 14:05
Console.WriteLine(when.ToString("dd.mm.yyyy HH:mm", inv)); // 07.05.2026 14:05
Console.WriteLine(when.ToString("yyyy-MM-dd", inv));       // 2026-11-07

Tek harfli biçimler ("D", "d", "g") culture'ın kendi pattern'ini kullanır; ekranda kullanıcıya göstereceğin tarih için doğru seçim bunlar. Özel biçimde harflerin büyük-küçüklüğü önemli: MM ay, mm dakika, HH 24 saat, hh 12 saat. Altıncı satırdaki küçük bir yazım hatası kasımı mayısa çevirdi, çünkü saat 14:05'in dakikası 05.

İlginç bir ayrıntı: en-US çıktısında "2:05" ile "PM" arasındaki karakter normal boşluk değil. Bu makinede U+202F, yani dar bölünmez boşluk çıktı. Bu veri işletim sisteminin culture kütüphanesinden (ICU) geliyor ve sürümden sürüme değişebiliyor. Biçimlendirilmiş tarihi sabit bir string ile karşılaştıran testler bu yüzden bir gün sebepsiz yere kırılabilir.

Parse ederken culture daha da kritik:

Console.WriteLine(DateTime.Parse("03/04/2026", tr).ToString("yyyy-MM-dd", inv)); // 2026-04-03
Console.WriteLine(DateTime.Parse("03/04/2026", us).ToString("yyyy-MM-dd", inv)); // 2026-03-04

bool ok = DateTime.TryParseExact("07.11.2026", "dd.MM.yyyy", inv, DateTimeStyles.None, out DateTime parsed);
Console.WriteLine($"{ok} {parsed:yyyy-MM-dd}"); // True 2026-11-07
bool bad = DateTime.TryParseExact("2026-13-01", "yyyy-MM-dd", inv, DateTimeStyles.None, out _);
Console.WriteLine(bad);                         // False

Aynı metin Türkiye'de 3 Nisan, ABD'de 4 Mart oldu. Exception yok, uyarı yok, sadece yanlış bir tarih. Formatını bildiğin bir girdiyi ParseExact ya da TryParseExact ile oku. Makineden makineye giden veride (dosya, JSON, log) ise ISO 8601 biçimini ("yyyy-MM-dd" ya da "o") ve invariant culture'ı kullan; o metni dünyanın her yerinde aynı şekilde geri okursun.

UTC'de Sakla, Yerelde Göster

16-utc-store-local-show-tr.png

Bütün bu parçaları birleştiren kural basit. Bir anı kaydederken UTC kullan: DateTime.UtcNow ya da DateTimeOffset.UtcNow. Veritabanına, dosyaya, başka bir servise UTC ya da offset'li değer gitsin. Kullanıcıya gösterirken, en son adımda, onun zaman dilimine ve culture'ına çevir. Saati ve zaman dilimi olmayan değerler (doğum günü, yayın tarihi, açılış saati) için DateOnly ve TimeOnly kullan.

DateTime.Now'ı da iki yerde kullanmaktan kaçın. Birincisi kayıt: sunucunun zaman dilimi değiştiğinde ya da uygulama başka bir bölgeye taşındığında eski kayıtlar sessizce anlam değiştirir. İkincisi süre ölçmek: bir işlemin ne kadar sürdüğünü ölçmek için System.Diagnostics.Stopwatch var; saat ayarı ya da yaz saati geçişi onu etkilemez.

Zamanı test edilebilir kılmak ayrı bir konu. DateTime.UtcNow'ı doğrudan çağıran bir kodu "yarın" için test etmek zordur. .NET 8 ile bunun için TimeProvider geldi; Asenkron Programlama serisinin .NET 8 yazısında göreceğiz.

Sık Düşülen Tuzaklar

DateTime eşitliğinin Kind'a baktığını sanmak. Bakmaz. Farklı türdeki değerleri karşılaştırmadan önce ikisini de UTC'ye çevir ya da baştan DateTimeOffset kullan.

Kind vermeden DateTime kurup ToUniversalTime() çağırmak. Unspecified değer yerel saat sayılır ve sonuç kodun çalıştığı makineye göre değişir. Veritabanından okunan tarihler de çoğu zaman Unspecified gelir; değerin UTC olduğunu biliyorsan okuduğun anda DateTime.SpecifyKind(value, DateTimeKind.Utc) ile bunu açıkça belirt.

Days, Hours, Minutes property'lerini toplam süre sanmak. Toplam için TotalDays, TotalHours ya da doğrudan TimeSpan karşılaştırması kullan.

Biçim string'inde mm ile MM'yi karıştırmak. Derleyici bunu yakalayamaz; çıktı geçerli görünen ama yanlış bir tarih olur.

Culture vermeden DateTime.Parse çağırmak. Kod senin makinende doğru çalışır, en-US culture'lı bir sunucuda gün ile ayı yer değiştirir.

Saati olmayan bir tarihi gece yarısına ayarlı DateTime olarak saklamak. Bir zaman dilimi dönüşümü onu bir önceki güne düşürebilir. Doğum günü bir DateOnly'dir.

Küçük Bir Deneme

★☆☆ Kitapçının açılış ve kapanış saatini TimeOnly olarak tanımla ve TimeOnly.FromDateTime(DateTime.Now) ile şu an açık olup olmadığını yazdır.

★★☆ Bir sipariş zamanı (DateTimeOffset) alıp siparişi İstanbul, New York ve Tokyo saatleriyle "yyyy-MM-dd HH:mm" biçiminde yazan bir fonksiyon yaz. Tokyo'nun IANA kimliği "Asia/Tokyo".

★★★ Teslim tarihinden itibaren 14 günlük iade süresinin son gününü hesapla; son gün bir cumartesi ya da pazara denk geliyorsa onu bir sonraki pazartesiye kaydır. DateOnly ve DayOfWeek ile yaz ve 2026-11-02 teslimatı için sonucu kontrol et.

Tarihleri ve saatleri doğru tuttuk, ama program kapandığında hepsi kayboluyor. Bir sonraki yazıda konsoldan girdi okuyor, File, Path ve Directory ile dosyalara yazıyor, using ve stream'lere ilk kez bakıyor ve encoding'in neden önemli olduğunu görüyoruz.