← blog · 27 Ekim 2026

Zaman Damgası Saklamak: An ile Duvar Saati, UTC, Saat Dilimi Adı ve Yaz Saati Tuzakları

Tarih hatalarının çoğu yaz saati geçişinde ya da bir ülke kural değiştirdiğinde ortaya çıkar. Geçmiş olayı an olarak, gelecek takvim olayını yerel saat ve dilim adıyla saklamak; doğru veritabanı tipi, yok olan ve iki kez yaşanan saatler, rapor gün sınırı ve tzdata güncellemesi.

Tarih ve saat hatalarının çoğu yazıldığı gün görünmez. Testler geçer, ekranlar doğru saati gösterir, sonra yılda iki kez saatler ileri geri alındığında bir rapor bir saat kayar, bir hatırlatma iki kez gider ya da hiç gitmez. Bir ülke yaz saati uygulamasını kaldırdığında gelecek ayın toplantıları yanlış saatte görünür. Bu hataların kökü genellikle aynıdır: "bir anı" saklamakla "bir duvar saatini" saklamak birbirine karıştırılmıştır.

İki farklı şey: an ve duvar saati

Bir zaman değeri iki farklı soruya cevap verebilir.

An (instant): "Bu olay evrensel zaman çizgisinde ne zaman oldu?" Bir siparişin oluşturulması, bir oturumun açılması, bir log satırı. Dünyanın neresinden bakarsanız bakın aynı noktadır. Doğru temsili UTC ya da epoch tabanlı bir değerdir.

Duvar saati (wall time): "Bu kişinin saatinde kaç gösteriyor?" Her sabah 09:00'da çalan alarm, İstanbul'da 14:00'te başlayacak bir toplantı, bir doğum günü. Bunun anlamı ancak bir saat dilimiyle birlikte vardır ve hangi ana denk geldiği, o dilimin kuralları değişirse değişir.

Kural basit: geçmişte olmuş olayları an olarak, gelecekte insanların takvimine göre olacak olayları duvar saati artı saat dilimi adı olarak saklayın. Yaklaşımların geri kalanı bu ayrımı ya bilerek ya da bilmeden bozar.

Üç saklama yaklaşımı ve hangisi nerede doğru

1. Her şeyi UTC'de saklamak. Geçmiş olaylar için doğru varsayılandır. Sıralama, aralık sorgusu ve karşılaştırma ucuzdur, sunucunun nerede çalıştığı önemsizdir. Ama gelecek tarihli insan olaylarında tuzaktır: "Haziranda İstanbul'da 10:00" toplantısını bugünkü kurallarla UTC'ye çevirip saklarsanız ve o bölgenin kuralı o tarihe kadar değişirse, sakladığınız an artık 10:00'a denk gelmez. Kural değişiklikleri teorik değildir; ülkeler yaz saatini kaldırıyor, ekliyor, geçiş tarihini kaydırıyor ve saat dilimi veritabanı (tzdata) her yıl birkaç kez güncelleniyor.

2. Ofsetle saklamak (+03:00). RFC 3339 biçimi bir anı kesin olarak ifade eder, iletişim için iyidir. Ama ofset bir saat dilimi değildir. +01:00 hem kışın Berlin'dir hem de yıl boyu Lagos'tur; ofsetten "bir hafta sonra aynı saatte" sorusunun cevabı çıkarılamaz, çünkü yaz saati geçişini bilmez. Ofseti saklayıp dilim adını atmak bilgi kaybıdır.

3. Yerel saat artı IANA dilim adı (Europe/Istanbul). Gelecek tarihli, tekrar eden ve insan takvimine bağlı olaylar için doğru olandır. Anı her ihtiyaç duyduğunuzda o günkü kurallarla hesaplarsınız. Bedeli: sorgu ve sıralama zorlaşır, her okumada dönüşüm gerekir.

Pratikte ikisi birlikte kullanılır: gelecek olayın kaynağı yerel saat artı dilimdir, ondan türetilen UTC an ise indekslenmek ve zamanlayıcıyı beslemek için ayrı bir sütunda tutulur ve tzdata güncellendiğinde yeniden hesaplanır.

Veritabanında doğru tip

PostgreSQL'de timestamptz adına rağmen saat dilimi saklamaz; gelen değeri UTC'ye çevirip an olarak tutar, okurken oturumun TimeZone ayarına göre gösterir. timestamp (dilimsiz) ise verdiğiniz duvar saatini hiçbir yoruma tabi tutmadan saklar. Yani an için timestamptz, duvar saati için timestamp artı ayrı bir dilim sütunu:

CREATE TABLE etkinlik (
  id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  baslik          text        NOT NULL,
  yerel_baslangic timestamp   NOT NULL,
  saat_dilimi     text        NOT NULL,
  olusturuldu     timestamptz NOT NULL DEFAULT now()
);

SELECT baslik,
       yerel_baslangic AT TIME ZONE saat_dilimi AS baslangic_ani
FROM etkinlik;

timestamp AT TIME ZONE 'dilim' ifadesi duvar saatini o dilimde yorumlayıp bir timestamptz an üretir. Tersi, yani timestamptz AT TIME ZONE 'dilim', anı o dilimdeki duvar saatine çevirir. Bu iki yönü karıştırmak en sık yapılan hatadır.

MySQL'de durum farklıdır: TIMESTAMP oturum dilimine göre UTC'ye çevrilerek saklanır ve 2038'de biten bir aralığa sahiptir, DATETIME ise dönüştürmeden saklar. Uzun ömürlü veride TIMESTAMP aralığını hesaba katın.

Sadece tarih olan alanlar (doğum günü, fatura tarihi, tatil günü) için zaman damgası kullanmayın; date tipi vardır. Doğum gününü gece yarısı UTC olarak saklamak, negatif ofsetli bir bölgede onu bir gün öne kaydırır.

Uygulama kodunda

Dillerin çoğu bu ayrımı tiplerle ifade eder ve kullanmak gerekir. Java'da Instant an, LocalDateTime dilimsiz duvar saati, ZonedDateTime ikisinin birleşimidir. Python'da dilim bilgisi taşıyan (aware) ve taşımayan (naive) datetime nesneleri vardır; naive nesneyle an temsil etmeye çalışmak sorunların yarısıdır. Python 3.12'den itibaren datetime.utcnow() kullanımdan kaldırılmış olarak işaretlidir, çünkü UTC değeri naive bir nesne olarak döndürür:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

simdi = datetime.now(timezone.utc)
istanbulda = simdi.astimezone(ZoneInfo("Europe/Istanbul"))

JavaScript'te Date her zaman bir andır (epoch milisaniye), dilim bilgisi tutmaz. Burada sinsi bir ayrıntı var: new Date("2026-10-27") biçimindeki yalnız tarih içeren ISO metin UTC olarak yorumlanır, new Date("2026-10-27T10:00") ise yerel saat olarak. Aynı kod sunucuda ve tarayıcıda farklı gün gösterebilir.

Sunucuların, konteynerlerin ve veritabanı oturumlarının saat dilimini UTC'ye sabitleyin. Bu doğruluğu sağlamaz ama kod bir yerde naive değer kullandığında hatanın her ortamda aynı biçimde görünmesini sağlar. Yerel geliştirme makinesinde İstanbul, sunucuda UTC olan bir ekip, "bende çalışıyor" hatalarını dilimden almaya başlar.

Yaz saati geçişinin iki tuzağı

Yaz saati uygulayan bir bölgede yılda bir gün bazı duvar saatleri hiç yoktur, bir gün de bazıları iki kez vardır. Örneğin Europe/Berlin dilimi için 29 Mart 2026'da saat 02:00'dan doğrudan 03:00'e geçilir, 02:30 diye bir an yoktur. 25 Ekim 2026'da ise 03:00'ten 02:00'ye dönülür ve 02:30 iki kez yaşanır.

Bunun sonuçları:

  • Kullanıcıdan alınan yerel saat yok olan bir saate düşebilir. Kütüphanelerin çoğu hata vermez, bir yöne kaydırır. Python'da zoneinfo sessizce bir ofset seçer; yok olan saati yakalamak için değeri UTC'ye çevirip geri getirmek ve aynı duvar saatine dönüp dönmediğine bakmak işe yarar:
from datetime import timezone

def duvar_saati_var_mi(dt):
    geri = dt.astimezone(timezone.utc).astimezone(dt.tzinfo)
    return geri.replace(tzinfo=None) == dt.replace(tzinfo=None)
  • İki kez yaşanan saatte hangisinin kastedildiği belirsizdir. Python bunu fold özniteliğiyle, Java ZonedDateTime.withEarlierOffsetAtOverlap() ve withLaterOffsetAtOverlap() ile ayırt eder. Ürün kararı olarak bir tanesini seçin ve belgeleyin.
  • "Her gün" tekrar eden işler o gün ya hiç çalışmaz ya iki kez çalışır. Sistem saatiyle çalışan bir zamanlayıcı 02:30 işini geçiş günlerinde atlayabilir ya da tekrarlayabilir. Kritik işleri UTC'ye göre zamanlayın ya da geçiş saatlerinin dışına koyun. Kubernetes CronJob'un timeZone alanı gibi açık dilim desteği varsa dilimi belirtin, düğümün yerel saatine bırakmayın.
  • "Bir gün ekle" ile "24 saat ekle" aynı şey değildir. Geçiş günü 23 ya da 25 saattir. Takvim aritmetiğini duvar saati üzerinde yapın, sonra ana çevirin.

Raporlarda gün sınırı

"Bugünün siparişleri" sorusu kimin bugünü olduğunu söylemeden cevaplanamaz. UTC gün sınırıyla gruplanan bir rapor, UTC+3'teki bir işletme için gece 03:00'te kapanan bir gün gösterir. Gruplamayı işletmenin diliminde yapın:

SELECT date_trunc('day', olusturuldu AT TIME ZONE 'Europe/Istanbul') AS gun,
       count(*)
FROM siparis
GROUP BY 1
ORDER BY 1;

Bu ifade üzerinden sık filtreleme yapacaksanız indeks de aynı ifadeye kurulmalıdır, yoksa olusturuldu üzerindeki düz indeks kullanılmaz. Çoğu durumda daha iyisi, sorgu tarafında gün sınırlarını ana çevirip olusturuldu >= ... AND olusturuldu < ... biçiminde düz aralık sorgusu yazmaktır.

tzdata güncellemesi bir dağıtım konusudur

Saat dilimi kuralları kodunuzun dışında, işletim sisteminin, dil çalışma zamanının ya da veritabanının yanında gelen tzdata paketinde yaşar. Bunun anlamı, bir ülke kuralını değiştirdiğinde düzeltmenin kod değişikliği değil bağımlılık güncellemesi olmasıdır. Konteyner imajlarınızın ve çalışma zamanlarınızın tzdata sürümünü güncel tutun; Java ve Python gibi kendi kopyasını taşıyan ortamlarda hangi kopyanın kullanıldığını bilin. Ayrı katmanlar farklı tzdata sürümü taşıyorsa, uygulama ile veritabanı aynı yerel saati farklı anlara çevirebilir.

Ne zaman bu kadarına gerek yok

Tek bir bölgede çalışan, yaz saati uygulamayan bir ülkeye hizmet eden ve gelecek tarihli takvim olayı tutmayan bir sistem için yerel saat artı dilim modelini kurmak gereksiz karmaşıklıktır. Orada her şeyi timestamptz olarak saklamak ve gösterimde sabit bir dilim kullanmak yeterlidir. Yine de an ile duvar saatini aynı tipe koymayın: sistem bir gün başka bir bölgeye açıldığında geri dönüp her sütunun ne anlama geldiğini çıkarmak, baştan doğru tipi seçmekten çok daha pahalıdır.

Kontrol listesi kısa: geçmiş olay için an, gelecek insan olayı için duvar saati artı IANA adı, yalnız tarih için date, sunucularda UTC, ofset dilim değildir, gün eklemek saat eklemek değildir, tzdata bir bağımlılıktır.