PostgreSQL'de Tarih, Saat ve Interval: now() ve clock_timestamp(), date_trunc, extract, AT TIME ZONE, age, date_bin ve generate_series
PostgreSQL'de now() transaction'ın başladığı anı döndürür ve transaction boyunca değişmez; gerçek saat için clock_timestamp() gerekir. AT TIME ZONE'un iki yönü, İstanbul gününe göre rapor, ay ekleme ve yaz saati kuralları, age ve justify_interval, extract, date_trunc ve date_bin ile zaman kovaları, generate_series ile boş günleri sıfırla doldurmak ve date_trunc'lı WHERE'in index'i neden kullanamadığı; SQL Server karşılıklarıyla.
Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (resmî postgres:18 Docker image'ı) üzerinde, 02'de kurduğumuz Bookstore lab'ında, şema yeniden yüklendikten hemen sonra çalıştırılmıştır; oturumun saat dilimi lab'ın varsayılanı olan Etc/UTC.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı), gece çalışan bir import job'unda her satıra "işlendiği an"ı yazıyor ve bunun için now() kullanıyor. Job tek bir transaction içinde çalışıyor. Aynı transaction'da üç saati okuyup bir buçuk saniye bekledik ve tekrar okuduk:
BEGIN;
SELECT now(), statement_timestamp(), clock_timestamp();
SELECT pg_sleep(1.5);
SELECT now(), statement_timestamp(), clock_timestamp();
COMMIT;
İkinci okumada hangi değerler değişir? Job bir saat sürseydi, bütün satırlardaki "işlendiği an" ne olurdu?
now | statement_timestamp | clock_timestamp
-------------------------------+------------------------------+-------------------------------
2026-11-15 00:13:15.353291+00 | 2026-11-15 00:13:15.35344+00 | 2026-11-15 00:13:15.353864+00
now | statement_timestamp | clock_timestamp
-------------------------------+-------------------------------+-------------------------------
2026-11-15 00:13:15.353291+00 | 2026-11-15 00:13:16.863345+00 | 2026-11-15 00:13:16.863506+00
now() transaction'ın başladığı anı döndürüyor ve transaction bitene kadar değişmiyor; bir saatlik job'daki bütün satırlar aynı zamanı alırdı. statement_timestamp() o sorgunun başladığı an, clock_timestamp() ise çağrıldığı andaki gerçek saat; tek bir sorgunun içinde bile satırdan satıra değişebilir. Bu davranış bilinçli: aynı transaction'ın yazdığı her şeyin aynı "an"ı taşıması çoğu zaman istenen şey. Satır satır gerçek süre gerekiyorsa clock_timestamp(). Bu yazıda PostgreSQL'in tarih, saat ve interval function'larını, saat dilimiyle gün sınırlarını ve raporlarda boş günleri doldurmayı görüyoruz. Tiplerin kendisini (timestamp ile timestamptz farkı) 03'te, aynı işlerin T-SQL karşılıklarını SQL Temelleri serisinin "T-SQL Tarih ve Saat Function'ları" yazısında anlattık.
Şimdiki Zaman: now(), current_date ve localtimestamp
SELECT now() = transaction_timestamp() AS same, current_timestamp, localtimestamp, current_date,
pg_typeof(localtimestamp) AS localtimestamp_type;
same | current_timestamp | localtimestamp | current_date | localtimestamp_type
------+-------------------------------+----------------------------+--------------+-----------------------------
t | 2026-11-15 00:13:16.864018+00 | 2026-11-15 00:13:16.864018 | 2026-11-15 | timestamp without time zone
now(), transaction_timestamp() ve standart yazımı current_timestamp aynı şey; üçü de timestamptz. localtimestamp aynı anın oturum saat dilimindeki duvar saati, tipi saat dilimsiz timestamp. current_date da oturumun saat dilimine göre bugünün tarihi: oturum UTC'deyken (Eşgüdümlü Evrensel Zaman) İstanbul'da saat 02:30 ise current_date hâlâ "dün". SQL Server'daki GETDATE() ve SYSDATETIME() sunucunun yerel saatini, SYSUTCDATETIME() UTC saatini verir; PostgreSQL'de ise timestamptz zaten bir an olduğu için ayrı bir "UTC versiyonu" yok, yalnızca gösterim değişiyor.
Saat Dilimi: TimeZone Ayarı ve AT TIME ZONE'un İki Yönü
Bookstore'un raporları Türkiye saatine göre gün sayıyor. UTC'de 30 Eylül 22:30'da verilen bir sipariş İstanbul'da 1 Ekim'e düşüyor:
SHOW TimeZone;
SELECT ts AS utc_instant,
ts AT TIME ZONE 'Europe/Istanbul' AS istanbul_wall_clock,
timestamp '2026-10-01 00:00' AT TIME ZONE 'Europe/Istanbul' AS istanbul_midnight,
date_trunc('day', ts) AS trunc_session_tz,
date_trunc('day', ts, 'Europe/Istanbul') AS trunc_istanbul
FROM (VALUES (timestamptz '2026-09-30 22:30:00+00')) AS v(ts);
TimeZone
----------
Etc/UTC
utc_instant | istanbul_wall_clock | istanbul_midnight | trunc_session_tz | trunc_istanbul
------------------------+---------------------+------------------------+------------------------+------------------------
2026-09-30 22:30:00+00 | 2026-10-01 01:30:00 | 2026-09-30 21:00:00+00 | 2026-09-30 00:00:00+00 | 2026-09-30 21:00:00+00
AT TIME ZONE iki yönlü çalışıyor ve sonuç tipi yön değiştiriyor. timestamptz AT TIME ZONE 'Europe/Istanbul' bir an alıyor, "İstanbul'da duvar saati o an kaçı gösteriyordu" sorusuna saat dilimsiz bir timestamp ile cevap veriyor (01:30). Tersi, timestamp AT TIME ZONE 'Europe/Istanbul' bir duvar saati alıyor ve "İstanbul'da gece yarısı hangi an" sorusunu bir timestamptz ile cevaplıyor (UTC 21:00). date_trunc('day', ...) varsayılan olarak oturumun saat dilimine göre kesiyor; üçüncü argümanla saat dilimini açıkça verebiliyorsun ve sonuç İstanbul gece yarısının anı oluyor. Rapor sorgusunun sonucu, sorguyu çalıştıran istemcinin TimeZone ayarına bağlı olmasın diye bu argümanı ya da AT TIME ZONE'u her zaman açıkça yaz.
Farkın rapora etkisi, 30 Eylül ile 1 Ekim arasındaki 48 saatlik siparişlerde:
SELECT (created_at AT TIME ZONE 'Europe/Istanbul')::date = date '2026-10-01' AS istanbul_oct1,
created_at::date = date '2026-10-01' AS utc_oct1,
count(*), min(order_number), max(order_number)
FROM orders
WHERE created_at >= '2026-09-30 00:00+00' AND created_at < '2026-10-02 00:00+00'
GROUP BY 1, 2
ORDER BY 1, 2;
istanbul_oct1 | utc_oct1 | count | min | max
---------------+----------+-------+-------------+-------------
f | f | 630 | BK-00984961 | BK-00985590
f | t | 90 | BK-00986311 | BK-00986400
t | f | 90 | BK-00985591 | BK-00985680
t | t | 630 | BK-00985681 | BK-00986310
Lab'da her iki dakikada bir sipariş var, yani günde 720. "1 Ekim" UTC'ye göre sayılırsa İstanbul'da 30 Eylül gecesi verilen 90 sipariş dışarıda kalıyor, İstanbul'da 2 Ekim'e geçmiş 90 sipariş içeride sayılıyor. Toplam sayı aynı çıktığı için hata göze batmıyor; ama gün gün ciro, kampanya günü satışı ya da "bugünün siparişleri" ekranı yanlış. İstanbul 2016'dan beri yaz saati uygulamıyor, yani fark sabit üç saat; Almanya ve Hollanda müşterileri için ise fark yılda iki kez değişiyor. Saat dilimi adını ('Europe/Istanbul') '+03' gibi sabit bir offset yerine kullanmak bu yüzden önemli.
Interval Aritmetiği: Ay Eklemek, age ve justify
SELECT date '2026-01-31' + interval '1 month' AS jan31_plus_1m,
date '2026-01-31' + interval '1 month' + interval '1 month' AS plus_twice,
date '2026-01-31' + interval '2 months' AS plus_2m,
date '2026-03-01' - date '2026-02-01' AS days_between,
pg_typeof(date '2026-03-01' - date '2026-02-01') AS diff_type;
SELECT timestamptz '2026-10-20 21:18:00+00' - timestamptz '2023-01-01 00:00:00+00' AS diff,
age(timestamptz '2026-10-20 21:18:00+00', timestamptz '2023-01-01 00:00:00+00') AS age_result,
justify_interval(interval '50 days 30 hours') AS justified,
extract(epoch FROM timestamptz '2026-10-20 21:18:00+00' - timestamptz '2023-01-01 00:00:00+00') / 86400 AS days;
jan31_plus_1m | plus_twice | plus_2m | days_between | diff_type
---------------------+---------------------+---------------------+--------------+-----------
2026-02-28 00:00:00 | 2026-03-28 00:00:00 | 2026-03-31 00:00:00 | 28 | integer
diff | age_result | justified | days
--------------------+---------------------------------+------------------------+-----------------------
1388 days 21:18:00 | 3 years 9 mons 19 days 21:18:00 | 1 mon 21 days 06:00:00 | 1388.8875000000000000
31 Ocak'a bir ay eklemek, Şubat'ta 31 olmadığı için ayın son gününe, 28 Şubat'a düşüyor. Bir ay daha eklemek 28 Mart veriyor, iki ayı tek seferde eklemek ise 31 Mart: aylık abonelik ya da taksit tarihi hesaplarken hep başlangıç tarihinden "n ay" ekle, bir önceki sonucun üstüne ekleme. Dikkat edilecek bir başka nokta tip: date + interval bir timestamp döndürüyor, date - date ise gün sayısını integer olarak. İki timestamptz'nin farkı gün ve saatten oluşan bir interval; age aynı farkı yıl ve ay olarak ifade ediyor. Lab'daki ilk ve son sipariş arası 1388 gün ya da "3 yıl 9 ay 19 gün". justify_interval, 30 saati bir gün ve 6 saate, 30 günü bir aya çeviriyor. Toplam saniye gerekiyorsa extract(epoch FROM ...).
SQL Server'ın DATEDIFF'i farklı bir soru soruyor: aradaki sınır sayısını. DATEDIFF(month, '2026-01-31', '2026-02-01') ay sınırı geçildiği için 1 döner; PostgreSQL'de age aynı iki tarih için 1 day diyor. Kodu taşırken "ay farkı" ifadesinin hangi anlamda kullanıldığını kontrol et.
Yaz Saati: interval '1 day' ile interval '24 hours' Aynı Değil
Almanya'da 29 Mart 2026 gecesi saatler bir saat ileri alınıyor. Berlin saatindeki bir oturumda aynı ana bir gün ve 24 saat ekledik, sonra aynı işi oturum UTC'deyken PostgreSQL 16 ile gelen date_add'in saat dilimi argümanıyla yaptık:
SET TimeZone = 'Europe/Berlin';
SELECT ts + interval '1 day' AS plus_1_day, ts + interval '24 hours' AS plus_24_hours
FROM (VALUES (timestamptz '2026-03-28 12:00 Europe/Berlin')) AS v(ts);
RESET TimeZone;
SELECT date_add(timestamptz '2026-03-28 11:00+00', interval '1 day', 'Europe/Berlin') AS date_add_berlin,
timestamptz '2026-03-28 11:00+00' + interval '1 day' AS plus_in_utc;
plus_1_day | plus_24_hours
------------------------+------------------------
2026-03-29 12:00:00+02 | 2026-03-29 13:00:00+02
date_add_berlin | plus_in_utc
------------------------+------------------------
2026-03-29 10:00:00+00 | 2026-03-29 11:00:00+00
'1 day' "yarın aynı duvar saati" anlamına geliyor ve o gün 23 saat sürdüğü için öğlen 12:00'ye düşüyor; '24 hours' ise tam 24 saat ekleyip 13:00'ü veriyor. timestamptz + interval gün kısmını oturumun saat dilimine göre hesapladığı için aynı sorgu UTC oturumunda farklı sonuç veriyor: date_add Berlin kurallarıyla 10:00 UTC'yi, düz toplama 11:00 UTC'yi buldu. Müşteriye "teslimat yarın aynı saatte" gibi bir şey gösteriyorsan saat dilimini açıkça veren date_add/date_subtract kullan.
Parçalar ve Kurucular: extract, make_date, to_char
SELECT extract(year FROM ts) AS y, extract(isodow FROM ts) AS isodow, extract(dow FROM ts) AS dow,
extract(week FROM ts) AS iso_week, extract(epoch FROM ts) AS epoch, date_part('hour', ts) AS h,
pg_typeof(extract(epoch FROM ts)) AS extract_type, pg_typeof(date_part('hour', ts)) AS date_part_type
FROM (VALUES (timestamptz '2026-11-15 09:45:30+00')) AS v(ts);
SELECT make_date(2026, 2, 29);
SELECT make_date(2026, 12, 31) AS d, make_timestamptz(2026, 11, 15, 9, 30, 0, 'Europe/Istanbul') AS tstz,
make_interval(days => 10, hours => 3) AS iv,
date_trunc('month', date '2026-02-14') + interval '1 month - 1 day' AS end_of_month,
(date_trunc('month', date '2026-02-14') + interval '1 month - 1 day')::date AS end_of_month_date;
y | isodow | dow | iso_week | epoch | h | extract_type | date_part_type
------+--------+-----+----------+-------------------+---+--------------+------------------
2026 | 7 | 0 | 46 | 1794735930.000000 | 9 | numeric | double precision
ERROR: date field value out of range: 2026-02-29
d | tstz | iv | end_of_month | end_of_month_date
------------+------------------------+------------------+------------------------+-------------------
2026-12-31 | 2026-11-15 06:30:00+00 | 10 days 03:00:00 | 2026-02-28 00:00:00+00 | 2026-02-28
15 Kasım 2026 Pazar: dow Pazar'ı 0 sayıyor, isodow ise haftayı uluslararası tarih standardına (ISO 8601) göre sayıyor ve 7 veriyor. Haftanın Pazartesi başladığı Türkiye raporlarında isodow ve week (ISO hafta numarası) doğru seçim. extract numeric, eski date_part ise double precision döndürüyor; dokümantasyon hassasiyet kaybı yüzünden extract'ı öneriyor. make_date geçersiz tarihi sessizce kaydırmıyor, hata veriyor. SQL Server'daki EOMONTH'un karşılığı tek bir function değil, ayın başına kesip bir ay ekleyip bir gün çıkarmak. Sonucun tipine dikkat: date_trunc bir date alınca onu timestamptz'ye çevirip kesiyor, sonuç da timestamptz; tarih olarak lazımsa sonda ::date gerekiyor.
Tarihi metne çevirmek için to_char:
SELECT to_char(ts AT TIME ZONE 'Europe/Istanbul', 'DD.MM.YYYY HH24:MI') AS tr_format,
to_char(ts, 'YYYY-MM-DD"T"HH24:MI:SSOF') AS iso_like,
to_char(ts, 'TMDay, DD TMMonth YYYY') AS long_en,
to_char(ts, 'IYYY-"W"IW') AS iso_week
FROM (VALUES (timestamptz '2026-11-15 09:45:30+00')) AS v(ts);
tr_format | iso_like | long_en | iso_week
------------------+------------------------+--------------------------+----------
15.11.2026 12:45 | 2026-11-15T09:45:30+00 | Sunday, 15 November 2026 | 2026-W46
to_char bir timestamptz'yi oturumun saat diliminde biçimlendiriyor; İstanbul saatini göstermek için önce AT TIME ZONE. TM öneki gün ve ay adlarını lc_time ayarının diliyle yazıyor; 18. yazıda gördüğümüz gibi resmî image'da yalnızca en_US locale'i olduğu için Türkçe ay adı gelmiyor. Türkçe tarih metnini uygulamada üretmek bu yüzden daha doğru. Tarihi metne çevirip karşılaştırma ya da sıralamada kullanma; bunlar timestamptz üzerinde yapılır.
Zaman Kovaları: date_trunc, date_bin ve generate_series
Raporlarda zamanı gruplamak, time series analizinde "bucket" denen aralıklara bölmek demek. Gün, hafta, ay gibi takvim birimleri için date_trunc; 15 dakika ya da 6 saat gibi sabit uzunluklar için PostgreSQL 14 ile gelen date_bin (SQL Server 2022'deki DATE_BUCKET'ın karşılığı):
SELECT date_bin(interval '15 minutes', created_at, timestamptz '2026-01-01') AS bucket, count(*)
FROM orders
WHERE created_at >= '2026-10-20 20:00+00'
GROUP BY 1
ORDER BY 1;
SELECT d AS berlin_midnight
FROM generate_series(timestamptz '2026-03-28 00:00 Europe/Berlin', timestamptz '2026-03-31 00:00 Europe/Berlin',
interval '1 day', 'Europe/Berlin') AS d;
bucket | count
------------------------+-------
2026-10-20 20:00:00+00 | 8
2026-10-20 20:15:00+00 | 7
2026-10-20 20:30:00+00 | 8
2026-10-20 20:45:00+00 | 7
2026-10-20 21:00:00+00 | 8
2026-10-20 21:15:00+00 | 2
berlin_midnight
------------------------
2026-03-27 23:00:00+00
2026-03-28 23:00:00+00
2026-03-29 22:00:00+00
2026-03-30 22:00:00+00
date_bin'in üçüncü argümanı kovaların hizalandığı başlangıç noktası; ay ve üstü birimler kabul edilmiyor. Lab'daki son sipariş 21:18'de, son kova bu yüzden iki siparişle bitiyor. generate_series zaman damgalarıyla da çalışıyor ve PostgreSQL 16'dan beri saat dilimi argümanı alıyor: Berlin gece yarıları yaz saati geçişinden sonra UTC'de 23:00'ten 22:00'ye kaydı, aralıklar 24 değil "bir takvim günü".
Bookstore: Boş Günleri Sıfırla Doldurmak
Günlük satış grafiğinde satış olmayan gün, sorgunun sonucunda hiç satır olmadığı için grafikte atlanıyor ve çizgi yanıltıcı biçimde düz devam ediyor. Çözüm, önce bütün günleri generate_series ile üretmek, satışları ona LEFT JOIN etmek. Gün sınırı İstanbul saatine göre:
WITH days AS (
SELECT d::date AS day
FROM generate_series(date '2026-10-17', date '2026-10-23', interval '1 day') AS d
),
sales AS (
SELECT (created_at AT TIME ZONE 'Europe/Istanbul')::date AS day, count(*) AS orders, sum(total_amount) AS revenue
FROM orders
WHERE created_at >= timestamptz '2026-10-17 00:00 Europe/Istanbul'
AND created_at < timestamptz '2026-10-24 00:00 Europe/Istanbul'
AND currency = 'TRY' AND status <> 'Cancelled'
GROUP BY 1
)
SELECT days.day, coalesce(sales.orders, 0) AS orders, coalesce(sales.revenue, 0) AS revenue
FROM days
LEFT JOIN sales USING (day)
ORDER BY days.day;
day | orders | revenue
------------+--------+----------
2026-10-17 | 469 | 36128.92
2026-10-18 | 472 | 37556.13
2026-10-19 | 463 | 37639.40
2026-10-20 | 474 | 38026.82
2026-10-21 | 6 | 606.41
2026-10-22 | 0 | 0
2026-10-23 | 0 | 0
Lab verisi UTC'ye göre 20 Ekim 21:18'de bitiyor; İstanbul'da bu 21 Ekim 00:18, o yüzden 21 Ekim'de 6 sipariş var, 22 ve 23 Ekim sıfır satırla geliyor. İki ayrıntı önemli: WHERE koşulu kolonu function'a sokmadan, İstanbul gece yarılarının anlarıyla yazıldı (aşağıda index'in neden önemsediğini göreceğiz) ve sayılar coalesce ile sıfıra çevrildi. Aynı yapı saatlik grafik için interval '1 hour' ve date_trunc('hour', ...) ile çalışıyor.
SQL Server'dan Gelenler İçin Kısa Eşleme
| T-SQL | PostgreSQL | Not |
|---|---|---|
GETDATE(), SYSDATETIME() | now(), clock_timestamp() | now() transaction boyunca sabit |
SYSUTCDATETIME() | now() | timestamptz zaten bir an |
DATEADD | + interval, date_add | ay sonu kuralı aynı |
DATEDIFF | date - date, age, extract(epoch ...) | DATEDIFF sınır sayar |
EOMONTH | date_trunc('month', d) + interval '1 month - 1 day' | sonuç timestamptz, ::date ekle |
DATETRUNC, DATE_BUCKET | date_trunc, date_bin | saat dilimi argümanı var |
DATEFROMPARTS | make_date | geçersiz tarihte hata |
AT TIME ZONE | AT TIME ZONE | IANA (saat dilimi veritabanını yöneten kuruluş) adları, Windows adları değil |
Index ve Tarih Koşulları
Raporun en sık yazılan yanlış hâli kolonu function'a sokan koşul. 07'de oluşturduğumuz created_at index'ini yeniden kurup iki yazımı karşılaştırdık:
CREATE INDEX ix_orders_created_at ON orders (created_at);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders WHERE date_trunc('day', created_at) = timestamptz '2026-10-01';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders WHERE created_at >= timestamptz '2026-10-01' AND created_at < timestamptz '2026-10-02';
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=2539 read=7771
-> Gather (actual rows=3.00 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=2539 read=7771
-> Partial Aggregate (actual rows=1.00 loops=3)
Buffers: shared hit=2539 read=7771
-> Parallel Seq Scan on orders (actual rows=240.00 loops=3)
Filter: (date_trunc('day'::text, created_at) = '2026-10-01 00:00:00+00'::timestamp with time zone)
Rows Removed by Filter: 333093
Buffers: shared hit=2539 read=7771
Planning:
Buffers: shared hit=25 read=1
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=3 read=3
-> Index Only Scan using ix_orders_created_at on orders (actual rows=720.00 loops=1)
Index Cond: ((created_at >= '2026-10-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-10-02 00:00:00+00'::timestamp with time zone))
Heap Fetches: 0
Index Searches: 1
Buffers: shared hit=3 read=3
Planning:
Buffers: shared hit=5 read=3
Aynı 720 satır için 10.310 buffer'a karşı 6 buffer. Index created_at değerlerine göre sıralı, date_trunc(created_at)'e göre değil; planlayıcı function'ın sonucunu index'te arayamıyor. Aralık yazımı (>= başlangıç, < ertesi günün başlangıcı) hem index'i kullanıyor hem de gün sonundaki mikrosaniyeleri kaçırmıyor; BETWEEN '...00:00' AND '...23:59:59' son saniyedeki kayıtları dışarıda bırakır.
Peki koşul gerçekten "İstanbul günü" olarak yazılacaksa ifadeye index konamaz mı? 06'daki expression index ile konabilir, ama ifadenin IMMUTABLE olması şart:
CREATE INDEX ix_orders_created_day ON orders (date_trunc('day', created_at));
CREATE INDEX ix_orders_created_day_ist ON orders (date_trunc('day', created_at, 'Europe/Istanbul'));
SELECT proname, provolatile, pg_get_function_identity_arguments(oid) AS args
FROM pg_proc
WHERE proname = 'date_trunc'
ORDER BY 3;
ERROR: functions in index expression must be marked IMMUTABLE
CREATE INDEX
proname | provolatile | args
------------+-------------+--------------------------------------
date_trunc | i | text, interval
date_trunc | i | text, timestamp without time zone
date_trunc | s | text, timestamp with time zone
date_trunc | i | text, timestamp with time zone, text
İki argümanlı date_trunc(text, timestamptz) STABLE (s): sonucu oturumun TimeZone ayarına bağlı, aynı girdiye her oturumda aynı cevabı vermiyor, bu yüzden index'e konamıyor. Saat dilimini argüman olarak alan üç argümanlı hâli PostgreSQL 16'dan beri IMMUTABLE (i) ve index oldu. Aynı iş ((created_at AT TIME ZONE 'Europe/Istanbul')::date) ifadesiyle de yapılabiliyor. Volatility kategorilerini ve kendi function'ını doğru işaretlemeyi 22. yazıda göreceğiz. Yine de çoğu durumda en basit çözüm, yukarıdaki gibi kolonun kendisine aralık koşulu yazmak; tek index her saat dilimindeki raporlara yetiyor.
Üretimde Dikkat
Sunucu ve database TimeZone'u UTC kalsın; dönüşümü sorguda AT TIME ZONE ya da saat dilimi argümanıyla açıkça yap. Raporlama aracının, psql'in ve uygulamanın oturumları farklı saat dilimlerinde açılabiliyor: bağlantı ayarı, ALTER ROLE ... SET timezone ya da ALTER DATABASE ... SET timezone oturumun değerini değiştirir. Uygulamanın gerçekten hangi değerle çalıştığını kendi bağlantısında SHOW TimeZone ile kontrol et.
Uzun süren job'larda "işlendiği an" için hangi saati istediğine karar ver: transaction'ın anı (now()) tutarlılık sağlar, satırın gerçek anı (clock_timestamp()) süre ölçümü sağlar. İkisini karıştıran log'larda "aynı saniyede 50.000 satır işlenmiş" gibi yanıltıcı görüntüler çıkıyor.
Saat dilimi kuralları değişebiliyor (Türkiye 2016'da kalıcı yaz saatine geçti). PostgreSQL bu kuralları IANA saat dilimi veritabanından alıyor; kaynak koddan derlenen PostgreSQL kendi kopyasını minor sürümlerle günceller, resmî Debian tabanlı image ise işletim sisteminin tzdata paketini kullanıyor (--with-system-tzdata). Image'ı güncel tutmak bu yüzden yalnızca güvenlik için değil, doğru saat için de gerekli.
Sık Düşülen Tuzaklar
Saat dilimsiz timestamp kolona offset'li değer yazmak ve DEFAULT 'now' kullanmak. İkisini aynı tabloda denedik:
CREATE TEMP TABLE import_log
(
id int,
imported_at timestamp,
created_at timestamptz DEFAULT 'now',
logged_at timestamptz DEFAULT now()
);
INSERT INTO import_log (id, imported_at) VALUES (1, '2026-11-15 10:00:00+03');
SELECT pg_sleep(1);
INSERT INTO import_log (id, imported_at) VALUES (2, '2026-11-15 10:00:00+03');
SELECT id, imported_at, created_at, logged_at FROM import_log;
SELECT pg_get_expr(adbin, adrelid) AS default_expr FROM pg_attrdef WHERE adrelid = 'import_log'::regclass;
id | imported_at | created_at | logged_at
----+---------------------+------------------------------+-------------------------------
1 | 2026-11-15 10:00:00 | 2026-11-15 00:13:17.26475+00 | 2026-11-15 00:13:17.265653+00
2 | 2026-11-15 10:00:00 | 2026-11-15 00:13:17.26475+00 | 2026-11-15 00:13:18.267119+00
default_expr
----------------------------------------------------------
'2026-11-15 00:13:17.26475+00'::timestamp with time zone
now()
timestamp kolon +03'ü hatasız ve uyarısız attı: değer "10:00" olarak kaldı, hangi saat diliminde olduğu bilgisi kayboldu. DEFAULT 'now' ise tablo oluşturulduğu anda bir sabite çevrildi; iki satır da aynı, eski zamanı aldı. Doğrusu DEFAULT now(): function çağrısı her insert'te değerlendiriliyor.
Transaction içinde now()'ın ilerlediğini sanmak: bir batch'te "son 5 dakikada eklenenler" sorgusu, transaction 10 dakikadır açıksa beklenmedik sonuç verir.
Gün sınırını oturum saat dilimine bırakmak: created_at::date ve iki argümanlı date_trunc istemcinin TimeZone'una göre çalışıyor; aynı rapor iki ekipte iki farklı sayı verir.
WHERE date_trunc(...) = ... ya da WHERE created_at::date = ... yazıp index beklemek: kolonu function'a sokan her koşul, o ifade için ayrıca index yoksa tabloyu baştan sona tarar.
Küçük Bir Deneme
★☆☆ Oturumda SET TimeZone = 'America/New_York'; yapıp SELECT now(), current_date, localtimestamp, now()::date; çalıştır. Hangi değerler UTC oturumundakinden farklı, hangisi aynı an?
★★☆ Bookstore'un Almanya'daki müşterileri için "Berlin saatine göre hafta" bazında sipariş sayılarını, boş haftalar sıfırla dolu olacak şekilde çıkar. Haftalar Pazartesi başlasın; date_trunc('week', created_at, 'Europe/Berlin') ve generate_series ile.
★★★ orders'a ((created_at AT TIME ZONE 'Europe/Istanbul')::date) üzerinde bir expression index ekle. WHERE (created_at AT TIME ZONE 'Europe/Istanbul')::date = '2026-10-01' sorgusunun bu index'i kullandığını, aralık yazımının ise ix_orders_created_at'i kullandığını EXPLAIN ile göster ve iki index'in boyutunu pg_relation_size ile karşılaştır.
Bir sonraki yazıda aggregate, array ve koşullu function'lara geçiyoruz: FILTER ile koşullu toplama, sıralı string_agg ve array_agg, unnest, = ANY ile array parametresi ve GREATEST'in NULL davranışı.