Mert Özen Her Satırda Daha İleri
Veritabanı SQL Temelleri — #20

T-SQL Tarih ve Saat Function'ları: SYSDATETIME ve SYSUTCDATETIME, DATEADD, DATEDIFF, EOMONTH, DATEFROMPARTS ve AT TIME ZONE

Mert Özen 15 Kas 2026 16 dk 8 görüntülenme
T-SQL Tarih ve Saat Function'ları: SYSDATETIME ve SYSUTCDATETIME, DATEADD, DATEDIFF, EOMONTH, DATEFROMPARTS ve AT TIME ZONE

T-SQL'de tarih function'ları oturum ayarına ve tipe duyarlıdır: Türkçe oturumda '2026-04-03' DATETIME'a 4 Mart olarak çevrilir, DATEDIFF süre değil sınır sayar ve haftayı hep pazar başlatır. SYSUTCDATETIME, EOMONTH, DATEFROMPARTS, ISO_WEEK, BETWEEN'in .997 tuzağı, UTC'den İstanbul saatine iki adımlı AT TIME ZONE ve yaz saati geçişleri gerçek çıktılarla, PostgreSQL karşılıklarıyla.

Sürüm notu: Bu yazıdaki SQL sorguları SQL Server 2025 (17.0, Cumulative Update 10) üzerinde serinin Bookstore veritabanıyla, PostgreSQL karşılaştırmaları PostgreSQL 18.7 üzerinde çalıştırılmıştır.

Önce Tahmin Et: ISO Tarihi Her Zaman Güvenli mi?

07. yazıda tarih literal'larını '2025-03-31' gibi ISO (International Organization for Standardization; uluslararası standartları yayımlayan kuruluş) 8601 biçiminde, yani yıl-ay-gün sırasıyla yazmayı önermiştik. Oturumun dilini Türkçe yapıp aynı literal'ı iki farklı tipe çevirelim. Dört column'un hepsi 3 Nisan 2026 mı?

SET LANGUAGE Turkish;
SELECT CAST('2026-04-03' AS DATE) AS as_date,
       CAST('2026-04-03' AS DATETIME) AS as_datetime,
       CAST('20260403' AS DATETIME) AS unseparated,
       CAST('2026-04-03T00:00:00' AS DATETIME) AS iso_with_t;
SET LANGUAGE us_english;
Dil ayarı 'Türkçe' olarak değiştirildi.
as_date     as_datetime              unseparated              iso_with_t
----------  -----------------------  -----------------------  -----------------------
2026-04-03  2026-03-04 00:00:00.000  2026-04-03 00:00:00.000  2026-04-03 00:00:00.000

(1 rows affected)
Changed language setting to us_english.

Hayır. DATE literal'ı doğru okudu, ama eski DATETIME tipi aynı metni 4 Mart olarak okudu. Türkçe dil ayarı tarih sırasını gün-ay-yıl (dmy) yapar ve DATETIME tireli yyyy-mm-dd biçimini bu sıraya göre yorumlar. Tiresiz '20260403' ve T ayraçlı '2026-04-03T00:00:00' ise her dilde aynı okunur. Ay 12'den büyük bir gün içerseydi ('2026-04-13') hata alırdın; hem de Türkçe: "varchar veri türünden bir datetime veri türüne dönüştürme aralık dışı bir değerle sonuçlandı." Uygulamanın connection string'inde ya da login'in varsayılan dilinde Türkçe seçiliyse bu fark production'da ortaya çıkar.

Önceki yazıda string function'larını ele almıştık. Bu yazının konusu tarih ve saat function'ları: şimdiki zamanı almak, tarih üretmek, DATEDIFF'in sınır sayma mantığı, dil ve DATEFIRST ayarının etkisi, yarı açık aralıklar ve AT TIME ZONE. Örnekler sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) UTC (Coordinated Universal Time; koordineli evrensel zaman) saklayan orders table'ından.

Şimdiki Zaman ve Tarih Üretmek

T-SQL'de "şimdi"yi veren altı ifade var ve tipleri farklı:

SELECT 'GETDATE()' AS fn, CAST(SQL_VARIANT_PROPERTY(GETDATE(), 'BaseType') AS VARCHAR(20)) AS base_type, CONVERT(VARCHAR(40), GETDATE(), 121) AS value
UNION ALL SELECT 'CURRENT_TIMESTAMP', CAST(SQL_VARIANT_PROPERTY(CURRENT_TIMESTAMP, 'BaseType') AS VARCHAR(20)), CONVERT(VARCHAR(40), CURRENT_TIMESTAMP, 121)
UNION ALL SELECT 'SYSDATETIME()', CAST(SQL_VARIANT_PROPERTY(SYSDATETIME(), 'BaseType') AS VARCHAR(20)), CONVERT(VARCHAR(40), SYSDATETIME(), 121)
UNION ALL SELECT 'SYSUTCDATETIME()', CAST(SQL_VARIANT_PROPERTY(SYSUTCDATETIME(), 'BaseType') AS VARCHAR(20)), CONVERT(VARCHAR(40), SYSUTCDATETIME(), 121)
UNION ALL SELECT 'SYSDATETIMEOFFSET()', CAST(SQL_VARIANT_PROPERTY(SYSDATETIMEOFFSET(), 'BaseType') AS VARCHAR(20)), CONVERT(VARCHAR(40), SYSDATETIMEOFFSET(), 121)
UNION ALL SELECT 'CURRENT_DATE', CAST(SQL_VARIANT_PROPERTY(CURRENT_DATE, 'BaseType') AS VARCHAR(20)), CONVERT(VARCHAR(40), CURRENT_DATE, 121);
fn                   base_type       value
-------------------  --------------  ----------------------------------
GETDATE()            datetime        2026-11-14 22:48:04.770
CURRENT_TIMESTAMP    datetime        2026-11-14 22:48:04.770
SYSDATETIME()        datetime2       2026-11-14 22:48:04.7722955
SYSUTCDATETIME()     datetime2       2026-11-14 22:48:04.7722955
SYSDATETIMEOFFSET()  datetimeoffset  2026-11-14 22:48:04.7722955 +00:00
CURRENT_DATE         date            2026-11-14

(6 rows affected)

GETDATE() ve standarttaki CURRENT_TIMESTAMP aynı şey: sunucunun yerel saati, eski DATETIME tipinde, yani milisaniyenin üçte biri hassasiyetinde (.770). SYSDATETIME() yerel saati DATETIME2(7) ile, SYSUTCDATETIME() UTC'yi, SYSDATETIMEOFFSET() ise yerel saati ofsetiyle birlikte verir. CURRENT_DATE 2025 ile geldi (18. yazı). Lab container'ının saat dilimi UTC olduğu için burada yerel ve UTC değerler aynı; İstanbul'daki bir sunucuda SYSDATETIME() ile SYSUTCDATETIME() arasında üç saat olur. Bookstore UTC sakladığı için yeni bir row'un zamanını SYSUTCDATETIME() ile yaz; GETDATE() sunucunun bulunduğu yere bağlı bir değer üretir.

Tarih üretmek için en çok kullanılanlar EOMONTH, DATEFROMPARTS ailesi ve 2022 ile gelen DATETRUNC (17. yazı):

SELECT EOMONTH('2026-02-10') AS end_feb, EOMONTH('2026-02-10', 1) AS end_mar, EOMONTH('2028-02-10') AS leap_feb,
       DATEADD(DAY, 1, EOMONTH('2026-06-20', -1)) AS first_of_month,
       DATEFROMPARTS(2026, 6, 1) AS from_parts,
       DATETIME2FROMPARTS(2026, 6, 1, 9, 30, 0, 0, 0) AS dt2_from_parts,
       DATETRUNC(MONTH, CAST('2026-06-20 14:05' AS DATETIME2(0))) AS trunc_month;
end_feb     end_mar     leap_feb    first_of_month  from_parts  dt2_from_parts       trunc_month
----------  ----------  ----------  --------------  ----------  -------------------  -------------------
2026-02-28  2026-03-31  2028-02-29  2026-06-01      2026-06-01  2026-06-01 09:30:00  2026-06-01 00:00:00

EOMONTH'un ikinci argümanı ay kaydırır; artık yılları kendisi bilir. "Ayın ilk günü" için eskiden DATEADD(DAY, 1, EOMONTH(x, -1)) yazılırdı; 2022'den beri DATETRUNC(MONTH, x) daha okunur. DATEFROMPARTS parçalardan biri NULL ise NULL döndürür, geçersiz bir tarihte ise hata verir:

SELECT DATEFROMPARTS(2026, 2, 30) AS bad_date;
Msg 289, Level 16, State 1, Line 1
Cannot construct data type date, some of the arguments have values which are not valid.

DATEADD'in ay sonu davranışını (31 Ocak + 1 ay = 28 Şubat) 07. yazıda görmüştük; PostgreSQL'de date '2026-01-31' + interval '1 month' de aynı sonucu, 2026-02-28'i veriyor.

DATEDIFF: Süre Değil Sınır Sayar

07. yazıda DATEDIFF(YEAR, '2025-12-31', '2026-01-01')'in 1 döndürdüğünü, yani DATEDIFF'in iki tarih arasından geçen sınırları saydığını görmüştük. Bunun iki sonucu daha var. Birincisi yaş hesabı:

DECLARE @birth DATE = '2000-12-31', @today DATE = '2026-06-20';
SELECT DATEDIFF(YEAR, @birth, @today) AS datediff_year,
       DATEDIFF(YEAR, @birth, @today)
         - CASE WHEN DATEADD(YEAR, DATEDIFF(YEAR, @birth, @today), @birth) > @today THEN 1 ELSE 0 END AS age;
datediff_year  age
-------------  ---
26             25

31 Aralık 2000'de doğan biri 20 Haziran 2026'da 25 yaşında, ama DATEDIFF 26 yıl sınırı saydı. Doğru hesap, yıl farkını doğum tarihine ekleyip bugünü geçiyorsa bir çıkarmak. PostgreSQL'de age(date '2026-06-20', date '2000-12-31') "25 years 5 mons 20 days" döndürüyor; yıl kısmı doğru.

İkincisi hafta. DATEDIFF(WEEK, ...) oturumun DATEFIRST ayarına bakmaz, haftanın her zaman pazar günü başladığını varsayar:

SET DATEFIRST 1;
SELECT DATEPART(WEEKDAY, '2026-06-20') AS weekday_monday_first,
       DATEDIFF(WEEK, '2026-06-20', '2026-06-21') AS week_diff_sat_sun,
       DATEDIFF(WEEK, '2026-06-21', '2026-06-22') AS week_diff_sun_mon;
SET DATEFIRST 7;
weekday_monday_first  week_diff_sat_sun  week_diff_sun_mon
--------------------  -----------------  -----------------
6                     1                  0

Haftanın pazartesi başladığını söyledik ve DATEPART buna uydu: cumartesi 6. gün. Ama DATEDIFF cumartesiden pazara bir hafta, pazardan pazartesiye sıfır hafta saydı. Microsoft Learn bunu bilerek yapılmış bir tercih olarak açıklıyor: function'ın sonucu oturum ayarından bağımsız kalsın diye. "Kaç hafta geçti" sorusunu pazartesi başlangıçlı haftalarla soruyorsan gün farkını 7'ye böl ya da hafta başlangıçlarını DATETRUNC(ISO_WEEK, ...) ile karşılaştır.

Üçüncü bir sınır da sonucun tipi. DATEDIFF INT döndürür; bir yıl içindeki milisaniyeler bile ona sığmaz:

SELECT DATEDIFF(MILLISECOND, '2025-01-01', '2026-01-01') AS ms_in_2025;
Msg 535, Level 16, State 1, Line 1
The datediff function resulted in an overflow. The number of dateparts separating two date/time instances is too large. Try to use datediff with a less precise datepart.
SELECT DATEDIFF_BIG(MILLISECOND, '2025-01-01', '2026-01-01') AS ms_in_2025,
       DATEDIFF_BIG(MILLISECOND, '1970-01-01', '2026-01-01') AS unix_ms;
ms_in_2025   unix_ms
-----------  -------------
31536000000  1767225600000

DATEDIFF_BIG aynı mantıkla çalışır ama BIGINT döndürür. İkinci column JavaScript'in Date.now()'u gibi Unix zaman damgasını milisaniye olarak üretiyor; ters yönü, yani milisaniyeyi tarihe çevirmeyi, 18. yazıda DATEADD'in 2025'te kabul ettiği BIGINT ile yapmıştık.

DATEPART, DATENAME, SET LANGUAGE ve SET DATEFIRST

DATEPART tarihin bir parçasını sayı olarak, DATENAME ise metin olarak döndürür. İkisi de oturum ayarlarına bağlı:

SET LANGUAGE Turkish;
SELECT DATENAME(MONTH, '2026-06-20') AS month_name, DATENAME(WEEKDAY, '2026-06-20') AS day_name,
       DATEPART(WEEKDAY, '2026-06-20') AS weekday_no, @@DATEFIRST AS datefirst;
SET LANGUAGE us_english;
SELECT DATENAME(MONTH, '2026-06-20') AS month_name, DATENAME(WEEKDAY, '2026-06-20') AS day_name,
       DATEPART(WEEKDAY, '2026-06-20') AS weekday_no, @@DATEFIRST AS datefirst;
Dil ayarı 'Türkçe' olarak değiştirildi.
month_name  day_name   weekday_no  datefirst
----------  ---------  ----------  ---------
Haziran     Cumartesi  6           1

(1 rows affected)
Changed language setting to us_english.
month_name  day_name  weekday_no  datefirst
----------  --------  ----------  ---------
June        Saturday  7           7

(1 rows affected)

Aynı cumartesi Türkçe oturumda haftanın 6., İngilizce oturumda 7. günü. Dil ayarı DATEFIRST'i de değiştiriyor: Türkçede hafta pazartesi, us_english'te pazar başlıyor. Bir rapor WHERE DATEPART(WEEKDAY, ordered_at) IN (1, 7) ile hafta sonu siparişlerini sayıyorsa sonucu login'in diline göre değişir. Gün adını ya da numarasını iş kuralı olarak kullanma; gerekiyorsa sorgunun başında SET DATEFIRST'i açıkça ayarla ya da DATENAME'i yalnız ekranda gösterilecek metin için kullan.

Hafta numarası için iki seçenek var ve yıl sonunda ayrışıyorlar:

SELECT d AS dt, DATEPART(WEEK, d) AS week_no, DATEPART(ISO_WEEK, d) AS iso_week, DATENAME(WEEKDAY, d) AS day_name
FROM (VALUES (CAST('2025-12-28' AS DATE)), ('2025-12-29'), ('2025-12-31'), ('2026-01-01'), ('2026-01-04'), ('2026-01-05')) AS v(d);
dt          week_no  iso_week  day_name
----------  -------  --------  ---------
2025-12-28  53       52        Sunday
2025-12-29  53       1         Monday
2025-12-31  53       1         Wednesday
2026-01-01  1        1         Thursday
2026-01-04  2        1         Sunday
2026-01-05  2        2         Monday

WEEK her yılı 1 Ocak'ın haftasından başlatır ve DATEFIRST'e uyar; 2025'in son günleri 53. hafta, 4 Ocak 2026 ise 2. hafta oldu. ISO_WEEK ISO 8601'e göre sayar: hafta pazartesi başlar ve yılın ilk perşembesini içeren hafta 1. haftadır. Bu yüzden 29 Aralık 2025 2026'nın 1. haftası. Avrupa'daki raporlarda beklenen hafta numarası ISO olandır; PostgreSQL'in extract(week FROM ...)'i de ISO haftasını döndürüyor.

Tarih Aralıkları: Yarı Açık Aralık ve BETWEEN Tuzağı

Mart 2025'in siparişlerini üç farklı şekilde sayalım:

SELECT
    (SELECT COUNT(*) FROM dbo.orders WHERE ordered_at BETWEEN '2025-03-01' AND '2025-03-31') AS between_dates,
    (SELECT COUNT(*) FROM dbo.orders WHERE ordered_at BETWEEN '2025-03-01' AND '2025-03-31 23:59:59.997') AS between_997,
    (SELECT COUNT(*) FROM dbo.orders WHERE ordered_at >= '2025-03-01' AND ordered_at < '2025-04-01') AS half_open;
between_dates  between_997  half_open
-------------  -----------  ---------
25             28           28

'2025-03-31' 31 Mart'ın gece yarısı demek; ayın son günündeki üç sipariş (12:01, 13:52 ve 21:48'de) dışarıda kaldı. Bu tuzağı 06. yazıda görmüştük. Yaygın "çözüm" bitişi 23:59:59.997 yapmak; bu veride doğru sonuç verdi, ama doğruluğu column'un tipine bağlı. DATETIME değerleri saniyenin 1/300'üne yuvarlar ve .999 bir sonraki güne taşar:

DECLARE @t TABLE (event_at DATETIME);
INSERT INTO @t VALUES ('2025-03-31 23:59:59.997'), ('2025-04-01 00:00:00');
SELECT COUNT(*) AS march_999 FROM @t WHERE event_at BETWEEN '2025-03-01' AND '2025-03-31 23:59:59.999';
march_999
---------
2

1 Nisan'ın gece yarısındaki kayıt Mart'a sayıldı. Daha hassas bir DATETIME2(7) column'unda ise tersi oluyor:

DECLARE @t2 TABLE (event_at DATETIME2(7));
INSERT INTO @t2 VALUES ('2025-03-31 23:59:59.9985'), ('2025-04-01 00:00:00');
SELECT COUNT(*) AS march_997_dt2 FROM @t2 WHERE event_at BETWEEN '2025-03-01' AND '2025-03-31 23:59:59.997';
march_997_dt2
-------------
0

31 Mart 23:59:59.9985'teki kayıt Mart'a ait, ama .997'den büyük olduğu için kayboldu. Yarı açık aralık (>= başlangıç AND < bir sonraki başlangıç) her tipte ve her hassasiyette doğru çalışır; aralığın sonunu hesaplamak yerine bir sonraki dönemin başını yazarsın. Ayrıca 07. yazıda gördüğümüz gibi sargable'dır: YEAR(ordered_at) = 2025 index'i tararken bu yazım seek yapar.

AT TIME ZONE: UTC ve İstanbul Saati

SQL Server saat dilimlerini Windows adlarıyla tanır; Linux'ta da aynı adları kullanır ve listesi sys.time_zone_info'da:

SELECT name, current_utc_offset, is_currently_dst
FROM sys.time_zone_info
WHERE name IN ('UTC', 'Turkey Standard Time', 'W. Europe Standard Time', 'GMT Standard Time')
ORDER BY name;
name                     current_utc_offset  is_currently_dst
-----------------------  ------------------  ----------------
GMT Standard Time        +00:00              0
Turkey Standard Time     +03:00              0
UTC                      +00:00              0
W. Europe Standard Time  +01:00              0

Türkiye 2016'dan beri yaz saati uygulamıyor ve yıl boyu +03:00. Bookstore'un Almanya ve Hollanda'daki müşterileri ise W. Europe Standard Time'da ve onların ofseti yazın +02:00 olur. IANA (saat dilimi veritabanını yöneten kuruluş) adları kabul edilmiyor: AT TIME ZONE 'Europe/Istanbul' Msg 9820 hatası veriyor: The time zone parameter 'Europe/Istanbul' provided to AT TIME ZONE clause is invalid.

AT TIME ZONE'un kuralı şu: girdi ofsetsizse (DATETIME2) değerin zaten o saat diliminde olduğunu varsayar ve yalnız ofseti ekler; girdi DATETIMEOFFSET ise değeri o saat dilimine çevirir. Bu yüzden UTC saklanan bir değeri İstanbul saatine çevirmek iki adım ister:

SELECT order_id, ordered_at,
       ordered_at AT TIME ZONE 'UTC' AS utc_offset,
       ordered_at AT TIME ZONE 'UTC' AT TIME ZONE 'Turkey Standard Time' AS istanbul,
       ordered_at AT TIME ZONE 'Turkey Standard Time' AS wrong_way
FROM dbo.orders
WHERE order_id = 263;
order_id  ordered_at           utc_offset                  istanbul                    wrong_way
--------  -------------------  --------------------------  --------------------------  --------------------------
263       2025-12-31 23:06:27  2025-12-31 23:06:27 +00:00  2026-01-01 02:06:27 +03:00  2025-12-31 23:06:27 +03:00

Doğru yol önce "bu değer UTC" demek, sonra İstanbul'a çevirmek: yılbaşı gecesi 02:06. Son column tek adımla yazılmış ve yanlış: saat aynı kaldı, yalnız yanına +03:00 eklendi; bu, gerçekte üç saat önceki bir anı gösteriyor. Hata vermeyen ve ilk bakışta doğru görünen bir sonuç.

Yaz saatine geçilen ve geri dönülen günlerde ofsetsiz bir yerel saatin karşılığı belirsizdir:

SELECT CAST('2026-03-29 01:30' AS DATETIME2(0)) AT TIME ZONE 'W. Europe Standard Time' AS before_gap,
       CAST('2026-03-29 02:30' AS DATETIME2(0)) AT TIME ZONE 'W. Europe Standard Time' AS in_gap,
       CAST('2026-10-25 02:30' AS DATETIME2(0)) AT TIME ZONE 'W. Europe Standard Time' AS ambiguous;
before_gap                  in_gap                      ambiguous
--------------------------  --------------------------  --------------------------
2026-03-29 01:30:00 +01:00  2026-03-29 03:30:00 +02:00  2026-10-25 02:30:00 +02:00

29 Mart'ta Berlin'de 02:30 diye bir saat yok; SQL Server onu bir saat ileri taşıyıp 03:30 +02:00 yaptı. 25 Ekim'de 02:30 iki kez yaşanıyor; Microsoft Learn'de yazdığı gibi değişiklikten önceki ofset (+02:00) seçildi. UTC saklamanın asıl gerekçesi bu: UTC'de belirsiz saat yoktur, belirsizlik yalnız yerel saatten UTC'ye dönerken çıkar. Saat dilimi kuralları SQL Server'ın dışında tutulup zaman zaman değiştiği için AT TIME ZONE nondeterministic (aynı girdiye her zaman aynı sonucu garanti etmeyen) sayılır; bu yüzden persisted computed column'da ya da index'li view'da kullanılamaz.

Bookstore Raporları

Verinin "bugün"ünü 20 Haziran 2026 kabul edelim. "Bu ayın siparişleri" yarı açık aralıkla:

DECLARE @today DATE = '2026-06-20';
DECLARE @month_start DATE = DATETRUNC(MONTH, @today);
SELECT @month_start AS month_start, DATEADD(MONTH, 1, @month_start) AS next_month_start,
       COUNT(*) AS orders_this_month
FROM dbo.orders
WHERE ordered_at >= @month_start AND ordered_at < DATEADD(MONTH, 1, @month_start);
month_start  next_month_start  orders_this_month
-----------  ----------------  -----------------
2026-06-01   2026-07-01        15

Bu sorgu ayı UTC'ye göre kesiyor. Operasyon ekibi İstanbul'daysa ay sınırını önce İstanbul saatinde kurup sonra UTC'ye çevirmek gerekir. Ocak 2026 için fark:

DECLARE @local_start DATETIME2(0) = '2026-01-01', @local_end DATETIME2(0) = '2026-02-01';
DECLARE @utc_start DATETIME2(0) = CAST(@local_start AT TIME ZONE 'Turkey Standard Time' AT TIME ZONE 'UTC' AS DATETIME2(0)),
        @utc_end   DATETIME2(0) = CAST(@local_end   AT TIME ZONE 'Turkey Standard Time' AT TIME ZONE 'UTC' AS DATETIME2(0));
SELECT @utc_start AS utc_start, @utc_end AS utc_end,
       (SELECT COUNT(*) FROM dbo.orders WHERE ordered_at >= '2026-01-01' AND ordered_at < '2026-02-01') AS january_utc,
       (SELECT COUNT(*) FROM dbo.orders WHERE ordered_at >= @utc_start AND ordered_at < @utc_end) AS january_istanbul;
utc_start            utc_end              january_utc  january_istanbul
-------------------  -------------------  -----------  ----------------
2025-12-31 21:00:00  2026-01-31 21:00:00  32           33

İstanbul'un Ocak ayı UTC'de 31 Aralık 21:00'de başlıyor. Dönüşümü column'a değil iki sınır değerine uyguladığımız için sorgu sargable kaldı; ordered_at AT TIME ZONE ... ifadesini WHERE'e koysaydık her row için hesaplanırdı.

"Geçen yılın aynı haftası" için DATEADD(YEAR, -1, ...) yanlış bir başlangıç verir: 15 Haziran 2026 pazartesi, 15 Haziran 2025 ise pazar. Haftalık karşılaştırmada gün adları denk gelmeli; bu yüzden 52 hafta (364 gün) geri gideriz:

DECLARE @today DATE = '2026-06-20';
DECLARE @week_start DATE = DATETRUNC(ISO_WEEK, @today);
SELECT @week_start AS week_start, DATEADD(WEEK, -52, @week_start) AS last_year_week_start,
       DATEPART(ISO_WEEK, DATEADD(WEEK, -52, @week_start)) AS last_year_iso_week,
       (SELECT COUNT(*) FROM dbo.orders
        WHERE ordered_at >= @week_start AND ordered_at < DATEADD(DAY, 7, @week_start)) AS this_week,
       (SELECT COUNT(*) FROM dbo.orders
        WHERE ordered_at >= DATEADD(WEEK, -52, @week_start) AND ordered_at < DATEADD(WEEK, -51, @week_start)) AS same_week_last_year;
week_start  last_year_week_start  last_year_iso_week  this_week  same_week_last_year
----------  --------------------  ------------------  ---------  -------------------
2026-06-15  2025-06-16            25                  5          6

İki hafta da pazartesi başlıyor ve ikisi de ISO 25. hafta. 52 hafta geri gitmek çoğu yıl aynı ISO haftasına düşer, ama 53 haftalık bir ISO yılından sonra bir hafta kayar; o yıllarda karşılaştırmayı DATEPART(ISO_WEEK, ...) ve ISO yılına göre yapmak gerekir.

Son rapor teslim süresi: siparişten kargoya geçen saatler ve 48 saat içinde kargolanan sipariş sayısı. DATEDIFF(HOUR, ...) saat sınırı saydığı için ortalamayı dakikadan hesaplıyoruz:

SELECT DATETRUNC(MONTH, ordered_at) AS order_month,
       COUNT(*) AS shipped_orders,
       CAST(AVG(DATEDIFF(MINUTE, ordered_at, shipped_at) / 60.0) AS DECIMAL(6,1)) AS avg_hours,
       MAX(DATEDIFF(HOUR, ordered_at, shipped_at)) AS max_hours,
       SUM(CASE WHEN shipped_at < DATEADD(HOUR, 48, ordered_at) THEN 1 ELSE 0 END) AS within_48h
FROM dbo.orders
WHERE shipped_at IS NOT NULL AND ordered_at >= '2026-01-01' AND ordered_at < '2026-07-01'
GROUP BY DATETRUNC(MONTH, ordered_at)
ORDER BY order_month;
order_month          shipped_orders  avg_hours  max_hours  within_48h
-------------------  --------------  ---------  ---------  ----------
2026-01-01 00:00:00  32              50.7       79         13
2026-02-01 00:00:00  16              48.3       80         8
2026-03-01 00:00:00  18              52.7       79         7
2026-04-01 00:00:00  17              52.5       79         6
2026-05-01 00:00:00  11              53.5       79         4
2026-06-01 00:00:00  12              50.3       80         4

48 saat koşulunu DATEDIFF(HOUR, ...) <= 48 yerine shipped_at < DATEADD(HOUR, 48, ordered_at) olarak yazdım; ilki 48 saat 59 dakikayı da 48 sayardı.

Function Özeti ve PostgreSQL Karşılıkları

PostgreSQL sütunundaki ifadeleri kendi PostgreSQL 18 container'ımda çalıştırdım:

T-SQLNULL ve sınır davranışıPostgreSQL
SYSUTCDATETIME()DATETIME2(7), UTCnow() (timestamptz), localtimestamp
DATEADD(MONTH, n, d)Ay sonuna kırpar; NULL → NULLd + interval '1 month'
DATEDIFF(part, a, b)Sınır sayar, INT; hafta hep pazarb - a (gün), age, extract(epoch FROM ...)
EOMONTH(d [, n])DATE döndürürdate_trunc('month', d) + interval '1 month - 1 day'
DATEFROMPARTS(y, m, d)Parça NULL → NULL; geçersizde Msg 289make_date, geçersizde hata
DATETRUNC(part, d)2022+; WEEK DATEFIRST'e uyardate_trunc, hafta ISO
DATEPART(ISO_WEEK, d)Dil ayarından bağımsızextract(week FROM d)
x AT TIME ZONE 'Turkey Standard Time'Windows adları; ofsetsiz girdiye ofset eklerAT TIME ZONE 'Europe/Istanbul', IANA adları

PostgreSQL'de de AT TIME ZONE'un anlamı girdinin tipine göre tersine döner: timestamptz '2025-12-31 23:06:27+00' AT TIME ZONE 'Europe/Istanbul' ofsetsiz yerel saati (2026-01-01 02:06:27) döndürüyor, ofsetsiz bir timestamp ise o dilimde kabul edilip timestamptz'ye çevriliyor.

Üretimde Dikkat

Saklarken UTC, gösterirken yerel saat. Column'ları DATETIME2 olarak tanımla ve SYSUTCDATETIME() ile doldur; ofsetin kendisini saklamak gerekiyorsa (ör. müşterinin siparişi verdiği yerel saat de iş için önemliyse) DATETIMEOFFSET kullan. Raporlarda saat dilimi dönüşümünü column'a değil aralığın sınırlarına uygula; böylece sorgu index'i kullanmaya devam eder.

Oturum ayarları sonucu değiştirir. SET LANGUAGE ve SET DATEFIRST login'in varsayılan dilinden ya da connection string'den gelir ve DATETIME literal'larını, DATENAME'i ve DATEPART(WEEKDAY, ...)'i etkiler. Uygulamadan tarihleri string olarak değil, tipli parametre olarak gönder; Entity Framework Core (EF Core) ve Dapper bunu DateTime parametreleriyle zaten yapar. Script'lerde tarih literal'ı yazıyorsan ya tiresiz '20260403' ya da T ayraçlı ISO biçimini kullan.

Container'larda saat dilimi çoğunlukla UTC'dir, Windows sunucularda ise kurulum yerininkidir. GETDATE() ve SYSDATETIME() ile yazılmış bir kod, veritabanı başka bir bölgeye taşındığında sessizce farklı sonuç üretir. Lab'de çalışan bir sorgunun production'da farklı sonuç vermesinin sık sebeplerinden biri budur.

Sık Düşülen Tuzaklar

  • Tireli '2026-04-03' literal'ının DATETIME'da dil ayarına göre 3 Nisan ya da 4 Mart okunması.
  • Ay sonunu BETWEEN ... AND '... 23:59:59.997' ile yazmak; DATETIME'da .999 ertesi güne yuvarlanır, DATETIME2'de .997'den sonraki kayıtlar kaybolur.
  • DATEDIFF(YEAR, ...) ile yaş hesaplamak; DATEDIFF(WEEK, ...)'in DATEFIRST'e uyduğunu sanmak.
  • Milisaniye farkında DATEDIFF kullanıp Msg 535 almak; DATEDIFF_BIG kullan.
  • UTC değeri tek adımda AT TIME ZONE 'Turkey Standard Time' ile çevirmek; önce AT TIME ZONE 'UTC' yazmadan saat değişmez.
  • Hafta sonu ya da gün adı kontrolünü DATEPART(WEEKDAY, ...) ve DATENAME ile oturum ayarına bağlı yazmak.
  • WHERE YEAR(ordered_at) = 2026 ya da WHERE ordered_at AT TIME ZONE ... ile index'i kullanılamaz hâle getirmek.

Küçük Bir Deneme

★☆☆ customers.created_at'ten yararlanarak her müşterinin kaç gündür kayıtlı olduğunu "bugün" 2026-06-30 kabul ederek hesapla. DATEDIFF(DAY, ...) ile DATEDIFF(HOUR, ...) / 24 aynı sonucu veriyor mu? Vermiyorsa neden?

★★☆ Hafta sonu verilen siparişlerin oranını, sonucu oturumun dil ve DATEFIRST ayarından bağımsız olacak şekilde hesapla. Sorguyu SET LANGUAGE Turkish ve SET LANGUAGE us_english altında çalıştırıp sonuçların aynı kaldığını göster.

★★★ İstanbul saatine göre günlük sipariş sayısını veren bir rapor yaz: gün sınırlarını GENERATE_SERIES ile üret, her günün başlangıç ve bitişini UTC'ye çevir ve siparişleri yarı açık aralıkla say. 31 Aralık 2025 ve 1 Ocak 2026 için UTC'ye göre gruplanan bir raporla farkı karşılaştır.

Bir sonraki yazıda dönüşüm ve biçimlendirme function'larına geçiyoruz: CONVERT'in style kodları, TRY_CONVERT ve TRY_PARSE, FORMAT'ın maliyeti ve ISNULL, COALESCE, NULLIF, IIF ile CHOOSE'un ayrıntıları.