2026-09-07veri4 dk

Zaman dilimi hataları: UTC saklamak tek başına yetmiyor

UTC bir anı saklar, uygulamalar ise çoğu zaman niyet saklamak zorundadır. Geçmiş olay ile gelecekteki randevunun neden farklı tiplerde tutulması gerektiğini kodla anlatıyoruz.

"Her şeyi UTC sakla" tavsiyesi doğru ama eksik. Doğru, çünkü sunucular arasında tek referans olmadan zaman karşılaştırması yapılamaz. Eksik, çünkü UTC bir anı saklar; uygulamaların çoğu ise an değil, niyet saklamak zorundadır. Aradaki fark, üretimde en geç fark edilen hata sınıfını üretiyor.

Bir an ile bir niyet aynı şey değil

İki kayda bakın:

  • Kullanıcı 14:32'de siparişi verdi.
  • Kullanıcı önümüzdeki salı 09:00'a randevu aldı.

Birincisi gerçekten bir andır. Olmuş bitmiştir, evrensel bir noktaya karşılık gelir, UTC olarak saklamak tam olarak doğrudur.

İkincisi an değildir. "Salı sabah dokuz" ifadesi, o gün geldiğinde o bölgede duvardaki saatin dokuzu göstermesi anlamına gelir. Bunu bugünden UTC'ye çevirip saklarsanız, kaydettiğiniz şey duvar saati değil, bugünkü kurallara göre hesaplanmış bir andır. Kurallar değişirse kaydınız yanlış olur, çünkü niyet duvar saatiydi.

Kural tek cümleyle şu: geçmiş olaylar için UTC bir zaman damgası (timestamptz), gelecekteki yerel taahhütler için yerel duvar saati artı IANA bölge kimliği (timestamp + Europe/Istanbul) sakla. İkincisinde UTC değeri saklanan veri değil, sorgu anında türetilen bir görünümdür.

Bölge kimliği demek, ofset demek değil

Ofset (+03:00) bir andaki farktır, bölge (Europe/Istanbul) ise kuralların tamamıdır. Ofseti saklarsanız, kuralın gelecekte değişme ihtimalini atmış olursunuz.

Bu Türkiye için soyut bir risk değil. 2016'da yaz saati uygulaması kaldırılıp kalıcı UTC+3'e geçildiğinde, sorun kodun kendisinde değil, tzdata sürümü eski kalan sistemlerdeydi. Kurallar veritabanında güncellenene kadar o sistemler bir saat yanlış zaman üretti. Ofset saklayan kayıtlar ise güncelleme geldikten sonra bile yanlış kaldı; çünkü onlarda düzeltilecek bir kural yoktu, donmuş bir sayı vardı.

Pratik sonuç: konteyner imajınızda tzdata paketi var mı ve güncelleniyor mu? alpine tabanlı imajların çoğunda varsayılan olarak yoktur ve bölge sorguları sessizce UTC'ye düşer.

Postgres'te hangi tip ne yapıyor

Yaygın yanılgı, timestamptz'nin bölgeyi sakladığıdır. Saklamaz. UTC'ye çevirip bir an olarak tutar, çıktıda oturumun TimeZone ayarına göre gösterir.

SET TimeZone = 'Europe/Istanbul';
SELECT '2026-09-07 09:00'::timestamptz;   -- 2026-09-07 09:00:00+03
SET TimeZone = 'UTC';
SELECT '2026-09-07 09:00'::timestamptz;   -- 2026-09-07 06:00:00+00

Aynı satır, oturum ayarına göre farklı okunuyor. Bu bir hata değil, tipin tanımı. Hata, uygulamanın bağlantı havuzundaki oturum ayarına güvenmesi. Havuzdan gelen bağlantının TimeZone değeri, sizin beklediğiniz değer olmayabilir.

Gelecekteki randevu için doğru şema:

CREATE TABLE randevu (
  id          bigserial PRIMARY KEY,
  yerel_an    timestamp   NOT NULL,   -- duvar saati, bölgesiz
  bolge       text        NOT NULL,   -- 'Europe/Istanbul'
  olusturuldu timestamptz NOT NULL DEFAULT now()
);

Hatırlatma göndermek için gereken gerçek an, sorgu anında türetilir:

SELECT id, (yerel_an AT TIME ZONE bolge) AS utc_an
FROM randevu
WHERE (yerel_an AT TIME ZONE bolge) BETWEEN now() AND now() + interval '1 hour';

Bu ifade indeks kullanmaz. Randevu tablosu büyüdüğünde (yerel_an AT TIME ZONE bolge) üzerinde ifade indeksi kurmanız gerekir; bunun için bolge kolonunun sabit olması ya da bölge başına kısmi indeks açılması gerekir. Şemayı seçerken bu maliyeti baştan hesaba katın.

Var olmayan ve iki kez var olan saatler

Yaz saati uygulayan bölgelerde her yıl iki tuhaf durum oluşur. İleri alınan gecede yerel saatte bir aralık hiç yaşanmaz; geri alınan gecede bir aralık iki kez yaşanır.

Türkiye'de bu sorun yok, ama müşterileriniz Almanya'daysa var. Europe/Berlin bölgesinde 29 Mart 2026 sabahı 02:30 diye bir an yoktur:

const f = new Intl.DateTimeFormat("tr-TR", {
  timeZone: "Europe/Berlin",
  dateStyle: "short",
  timeStyle: "short",
});
// 01:30 UTC = Berlin'de 03:30 (02:00 -> 03:00 atlaması)
f.format(new Date("2026-03-29T01:30:00Z")); // 29.03.2026 03:30

Var olmayan saate randevu kaydı girilmesini engelleyecek yer, form doğrulamasıdır. Çevirinin kendisi sessizce bir sonuç üretir ve o sonucun yanlış olduğu hiçbir yerde belli olmaz.

JavaScript'te iki satır arasındaki fark

new Date("2026-03-29");            // UTC gece yarısı olarak yorumlanır
new Date("2026-03-29T00:00:00");   // yerel gece yarısı olarak yorumlanır

Bu ayrım şartnamede yazılıdır ve tarih-yalnız veriyi Date'e sokan her kod tabanında er geç bir gün kaymasına yol açar. Doğum tarihi, fatura tarihi, tatil günü gibi değerler zaman damgası değildir; DATE tipinde ya da 2026-03-29 metni olarak saklanmalı, hiçbir aşamada zaman dilimine çevrilmemelidir.

Aritmetik için de aynı uyarı geçerli: bir gün eklemek 86400 saniye eklemek değildir. Yaz saati geçişinin olduğu gün 23 ya da 25 saattir. Takvim aritmetiğini bölge farkındalığı olan bir kütüphaneyle yapın; çalışma ortamınız destekliyorsa Temporal API bu ayrımı tip düzeyinde veriyor, desteklemiyorsa köprü kütüphanelerinden biriyle aynı ayrımı elle koruyun.

Raporlarda gün sınırı

Son ve en sık atlanan nokta: "günlük satış" sorgusunu UTC gününe göre gruplarsanız, İstanbul'daki bir işletmenin gecesinin son üç saati ertesi güne yazılır.

SELECT (olusturuldu AT TIME ZONE 'Europe/Istanbul')::date AS gun,
       sum(tutar)
FROM siparis
GROUP BY 1;

İşletmenin günü gece yarısında bitmiyorsa (gece açık bir mekân için sabah 04:00'te bitiyorsa) o kaymayı da sorguya siz koyacaksınız. Rapor rakamı tutmuyor diye gelen şikâyetlerin önemli bir kısmı burada çözülüyor.

Bugün yapılacak kontrol

Kod tabanınızda üç grep yeter: tarih-yalnız değerlerin Date/timestamptz'ye çevrildiği yerler, gelecekteki randevu ya da hatırlatma kayıtlarının UTC olarak saklandığı yerler, ve ::date ile gruplayan rapor sorguları. Üçünü de bulduğunuzda hangi kayıtların an, hangilerinin niyet olduğuna karar verin. Şema düzeltmesi bu karardan sonra on satırlık iş.