Grouping Data Multi-Zona Waktu di PostgreSQL Tanpa Salah
Grouping Data Multi-Zona Waktu di PostgreSQL Tanpa Salah
Saya membangun dashboard yang mengagregasi event dari tempat-tempat yang jamnya berbeda-beda: sebuah venue di Adelaide, site di Kathmandu, server di New York, kantor di Jakarta. Dashboard-nya mengajukan pertanyaan yang kelihatan sepele — "berapa event per hari, per kota?" — dan satu kata yang diam-diam merusak semuanya adalah hari.
"Hari" itu bukan properti sebuah instant, melainkan properti sebuah instant
dilihat dari satu zona waktu tertentu. Instant yang sama bisa jatuh di hari
Selasa untuk satu kota dan Rabu untuk kota lain. Jadi sebelum menulis GROUP BY ... day, kita harus menjawab dulu: harinya siapa?
Tulisan ini adalah desain yang saya pakai di produksi, lengkap dengan SQL yang membuktikannya. Setiap tabel hasil di bawah diproduksi di PostgreSQL 17 — salin query-nya dan reproduksi sendiri. Jebakannya sama saja, mau kita mengelompokkan per hari lokal maupun per jam lokal, dan keduanya dibahas di sini.
Satu aturan: timestamp itu instant, bukan tanggal kalender
Simpan momen kejadian sebagai timestamptz. Meski namanya begitu, timestamptz
tidak menyimpan zona waktu — yang disimpan adalah instant absolut (internal
UTC) yang lalu ditampilkan di zona apa pun yang kita minta. Inilah yang kita
butuhkan untuk pengurutan, hitung durasi, dan menentukan "apa yang terjadi lebih
dulu."
Tanggal kalender lokal adalah proyeksi dari instant itu lewat satu zona tertentu. Di proyeksi inilah dashboard biasanya salah, karena shortcut yang kelihatannya paling masuk akal untuk menghitungnya ternyata keliru secara halus.
Shortcut yang kelihatan benar padahal salah
Ini contoh tabel events kecil. Tiap baris menyimpan instant-nya (occurred_at)
dan zona IANA tempat event terjadi (event_tz):
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
city text NOT NULL,
event_tz text NOT NULL, -- IANA zone where it happened
occurred_at timestamptz NOT NULL, -- source of truth (an instant)
local_date date NOT NULL, -- resolved ONCE at write time
local_time time NOT NULL
);
Saya insert datanya dengan menyebutkan waktu dinding lokal plus zonanya — karena begitulah aplikasi sebenarnya mengetahui sebuah event — lalu biarkan Postgres yang menurunkan instant UTC-nya:
INSERT INTO events (city, event_tz, occurred_at, local_date, local_time)
SELECT city, tz,
(wall AT TIME ZONE tz), -- local wall clock -> UTC instant
wall::date, -- stored local_date (frozen, correct)
wall::time
FROM (VALUES
('Adelaide','Australia/Adelaide', timestamp '2026-12-15 00:15'), -- DST, +10:30
('Adelaide','Australia/Adelaide', timestamp '2026-12-15 23:50'),
('Adelaide','Australia/Adelaide', timestamp '2026-12-16 00:30'),
('Adelaide','Australia/Adelaide', timestamp '2026-06-15 00:15'), -- standard, +9:30
('Kathmandu','Asia/Kathmandu', timestamp '2026-12-15 00:20'), -- +5:45
('New York','America/New_York', timestamp '2026-07-04 23:30'),
('New York','America/New_York', timestamp '2026-11-10 00:30'),
('Jakarta','Asia/Jakarta', timestamp '2026-12-15 00:10')
) v(city, tz, wall);
Nah, ini bagian berbahayanya. Untuk mengelompokkan event Adelaide ke bucket hari lokal, biasanya kita tergoda memakai salah satu dari ini:
- (a) tanggal UTC —
occurred_at::date - (b) offset angka yang di-hardcode —
occurred_at AT TIME ZONE interval '9:30'(Adelaide kan "UTC+9:30"?) - (c) singkatan zona waktu —
AT TIME ZONE 'ACST'/'EST'
Mari hitung hari yang dihasilkan tiap strategi dan bandingkan dengan local_date
yang kita simpan saat write:
SELECT id,
to_char(occurred_at,'YYYY-MM-DD HH24:MI') AS occurred_utc,
local_date AS stored_local_date,
(occurred_at AT TIME ZONE 'Australia/Adelaide')::date AS iana_correct,
(occurred_at AT TIME ZONE interval '9:30')::date AS fixed_plus_0930,
(occurred_at AT TIME ZONE 'UTC')::date AS bucket_utc,
(occurred_at AT TIME ZONE interval '10:00')::date AS whole_hour_plus10
FROM events WHERE city = 'Adelaide' ORDER BY id;
| occurred_utc | stored_local_date | iana_correct | fixed_plus_0930 | bucket_utc | whole_hour_plus10 |
|---|---|---|---|---|---|
| 2026-12-14 13:45 | 2026-12-15 | 2026-12-15 | ❌ 2026-12-14 | ❌ 2026-12-14 | ❌ 2026-12-14 |
| 2026-12-15 13:20 | 2026-12-15 | 2026-12-15 | 2026-12-15 | 2026-12-15 | 2026-12-15 |
| 2026-12-15 14:00 | 2026-12-16 | 2026-12-16 | ❌ 2026-12-15 | ❌ 2026-12-15 | 2026-12-16 |
| 2026-06-14 14:45 | 2026-06-15 | 2026-06-15 | 2026-06-15 | ❌ 2026-06-14 | 2026-06-15 |
Bandingkan baris 1 dengan baris 4. Offset hardcode +9:30 itu benar di Juni
tapi salah di Desember, untuk kota yang sama. Bucket UTC selalu salah setiap
kali waktu lokalnya dekat tengah malam. Hanya AT TIME ZONE 'Australia/Adelaide'
(nama IANA) dan local_date tersimpan yang konsisten benar di semua baris.
Ada dua hal terpisah yang sama-sama berperan di sini, dan masing-masing pantas dibahas tersendiri.
Selisih setengah jam saja sudah cukup untuk salah hari
Adelaide bukan UTC+10. Di waktu standar offset-nya +9:30, dan saat daylight
saving jadi +10:30. Setengah jam ekstra itu bukan sekadar detail kosmetik.
Event jam 00:15 waktu lokal baru lima belas menit masuk ke hari itu — geser tiga
puluh menit saja, ia langsung jatuh ke kemarin.
Itulah persisnya kenapa whole_hour_plus10 salah di baris 1 tadi: membulatkan
offset ke jam penuh memundurkan event yang baru lewat tengah malam sampai
melompati pergantian tanggal.
Kathmandu membuat poin ini benar-benar tak terbantahkan. Zonanya +5:45 — offset empat puluh lima menit, dan tanpa DST yang bisa dijadikan kambing hitam:
WITH k(label, occurred_at) AS (VALUES
('local 2026-12-15 00:20', timestamp '2026-12-15 00:20' AT TIME ZONE 'Asia/Kathmandu'),
('local 2026-12-15 23:50', timestamp '2026-12-15 23:50' AT TIME ZONE 'Asia/Kathmandu')
)
SELECT label,
to_char(occurred_at,'YYYY-MM-DD HH24:MI') AS utc,
(occurred_at AT TIME ZONE interval '5:00')::date AS round_down_p5,
(occurred_at AT TIME ZONE interval '6:00')::date AS round_up_p6,
(occurred_at AT TIME ZONE 'Asia/Kathmandu')::date AS iana_correct
FROM k;
| label | utc | round_down_p5 | round_up_p6 | iana_correct |
|---|---|---|---|---|
| local 2026-12-15 00:20 | 2026-12-14 18:35 | ❌ 2026-12-14 | 2026-12-15 | 2026-12-15 |
| local 2026-12-15 23:50 | 2026-12-15 18:05 | 2026-12-15 | ❌ 2026-12-16 | 2026-12-15 |
Membulatkan ke bawah ke +5:00 merusak baris pertama. Membulatkan ke atas
ke +6:00 merusak baris kedua. Tidak ada offset jam-penuh yang bisa
mengelompokkan zona +5:45 dengan benar, karena offset aslinya berada di antara
jam. Kode apa pun yang menyederhanakan zona jadi bilangan bulat jam akan salah
untuk sekitar seperenam populasi dunia.
Offset IANA bisa jatuh di kelipatan lima belas menit mana pun — Nepal +5:45,
Kepulauan Chatham +12:45 — dan itulah kenapa panduan timestamp database dari
Tinybird sampai di angka yang sama dari arah berbeda: lima belas menit adalah
bucket paling kasar yang masih aman untuk pra-agregasi sebelum sebagian zona
butuh penyesuaian per-zona saat query. Kalau rollup Anda per jam, zona setengah
jam dan 45 menit sudah diam-diam masuk ke kelompok yang salah.
DST membuat angka fixed apa pun salah separuh tahun
Misalkan pun Anda menyimpan offset setengah jam yang persis tepat, daylight saving tetap menggesernya dua kali setahun. Jadi "offset sebuah kota" sama sekali bukan konstanta yang bisa di-hardcode.
Gejala ini akan langsung kelihatan di dashboard — order per hari lokal untuk Adelaide — dihitung dengan empat cara:
-- (A) CORRECT: the stored local_date
SELECT local_date AS day, count(*) FROM events WHERE city='Adelaide' GROUP BY 1 ORDER BY 1;
-- (B) WRONG: hardcoded +9:30 offset
SELECT (occurred_at AT TIME ZONE interval '9:30')::date AS day, count(*) FROM events WHERE city='Adelaide' GROUP BY 1 ORDER BY 1;
-- (C) WRONG: bucket by UTC date
SELECT (occurred_at AT TIME ZONE 'UTC')::date AS day, count(*) FROM events WHERE city='Adelaide' GROUP BY 1 ORDER BY 1;
-- (D) CORRECT: IANA zone name
SELECT (occurred_at AT TIME ZONE 'Australia/Adelaide')::date AS day, count(*) FROM events WHERE city='Adelaide' GROUP BY 1 ORDER BY 1;
| day | (A) stored | (B) fixed +9:30 | (C) UTC | (D) IANA |
|---|---|---|---|---|
| 2026-06-14 | – | – | 1 | – |
| 2026-06-15 | 1 | 1 | – | 1 |
| 2026-12-14 | – | 1 | 1 | – |
| 2026-12-15 | 2 | 2 | 2 | 2 |
| 2026-12-16 | 1 | – | – | 1 |
Perhatikan apa yang dilakukan offset hardcode (B) terhadap grafik: ia mengarang 14 Desember yang sebenarnya nol order lokal, dan menghilangkan 16 Desember sepenuhnya. Bucket UTC (C) bahkan lebih meleset lagi. Operator yang membandingkan angka kemarin dengan hari ini akan membaca angka yang masuk ke hari yang salah — justru di minggu-minggu transisi DST, saat perhatian on-call sedang paling tinggi.
Pakai singkatan zona pun gagal dengan cara yang sama, karena singkatan seperti
EST itu offset fixed yang mengabaikan DST:
WITH n(label, occurred_at) AS (VALUES
('summer 2026-07-05 00:30 EDT', timestamp '2026-07-05 00:30' AT TIME ZONE 'America/New_York'),
('winter 2026-11-10 00:30 EST', timestamp '2026-11-10 00:30' AT TIME ZONE 'America/New_York')
)
SELECT label,
to_char(occurred_at,'YYYY-MM-DD HH24:MI') AS utc,
(occurred_at AT TIME ZONE 'EST')::date AS abbrev_est_fixed,
(occurred_at AT TIME ZONE 'America/New_York')::date AS iana_correct
FROM n;
| label | utc | abbrev_est_fixed | iana_correct |
|---|---|---|---|
| summer 2026-07-05 00:30 EDT | 2026-07-05 04:30 | ❌ 2026-07-04 | 2026-07-05 |
| winter 2026-11-10 00:30 EST | 2026-11-10 05:30 | 2026-11-10 | 2026-11-10 |
Di musim panas, New York berjalan pada EDT (-4), tapi 'EST' membeku di -5,
sehingga event yang baru lewat tengah malam terdorong mundur ke hari sebelumnya.
Pelajarannya: jangan pernah memberi singkatan atau angka ke AT TIME ZONE.
Beri ia nama IANA.
Grouping per jam pun salah karena alasan yang sama — plus dua lagi
Semua di atas soal hari kalender. Tampilan per jam — "jam tersibuk," "event per jam," laporan shift — mewarisi semua bug itu, ditambah dua bug baru yang hanya muncul di level jam.
Offset di bawah satu jam memecah satu jam lokal jadi dua
Grouping pakai jam UTC (atau offset jam-penuh apa pun), zona +5:45 atau +9:30
tidak akan pernah pas dengan batas bucket. Ini empat event Kathmandu yang semuanya
terjadi di dalam jam lokal 18:00 yang sama:
WITH e(occurred_at) AS (VALUES
(timestamp '2026-12-15 18:05' AT TIME ZONE 'Asia/Kathmandu'),
(timestamp '2026-12-15 18:20' AT TIME ZONE 'Asia/Kathmandu'),
(timestamp '2026-12-15 18:35' AT TIME ZONE 'Asia/Kathmandu'),
(timestamp '2026-12-15 18:50' AT TIME ZONE 'Asia/Kathmandu')
)
SELECT extract(hour from occurred_at)::int AS utc_hour,
extract(hour from occurred_at AT TIME ZONE 'Asia/Kathmandu')::int AS local_hour,
count(*)
FROM e GROUP BY 1,2 ORDER BY 1;
| utc_hour | local_hour | count |
|---|---|---|
| 12 | 18 | 3 |
| 13 | 18 | 1 |
Satu jam puncak yang sebenarnya nyata jadi pecah menjadi dua batang lebih pendek, di UTC 12 dan 13. Query "jam tersibuk" pun salah baca — dan zona 30 menit seperti Adelaide mengalami hal yang sama, karena setiap jam lokal selalu menyeberang ke dua jam UTC.
DST fall-back: satu jam lokal menampung dua jam event
Saat jam mundur, satu jam lokal terjadi dua kali. Adelaide mengakhiri DST pada
2026-04-05 waktu lokal — instant-nya 2026-04-04 16:30 UTC, saat jam bergeser
03:00 → 02:00. Tebar satu event tiap lima belas menit melewati titik transisi,
lalu kelompokkan per jam lokal:
WITH inst AS (
SELECT g AS occurred_at
FROM generate_series(timestamptz '2026-04-04 14:30+00',
timestamptz '2026-04-04 18:15+00', interval '15 min') g
)
SELECT extract(hour from occurred_at AT TIME ZONE 'Australia/Adelaide')::int AS local_hour,
count(*) AS events
FROM inst GROUP BY 1 ORDER BY 1;
| local_hour | events |
|---|---|
| 1 | 4 |
| 2 | 8 |
| 3 | 4 |
Jam lokal 02 menampung dua kali lipat event dibanding jam tetangganya — dua
jam nyata terhimpit jadi satu bucket. Lonjakannya memang nyata, tapi muncul di
grafik tiap musim gugur dan terbaca seperti lonjakan trafik betulan. Hanya
timestamptz yang bisa membedakan kedua jam 02:xx itu.
DST spring-forward: jam lokal yang mustahil ada
Transisi sebaliknya justru menghapus satu jam. Saat Adelaide memulai DST, jam
melompat 01:59 → 03:00, jadi lokal 02:00–02:59 tidak pernah ada:
SELECT to_char(g,'HH24:MI') AS utc,
to_char(g AT TIME ZONE 'Australia/Adelaide','HH24:MI') AS local
FROM generate_series(timestamptz '2026-10-03 16:00+00',
timestamptz '2026-10-03 17:00+00', interval '15 min') g;
utc | local
16:00 | 01:30
16:15 | 01:45
16:30 | 03:00 <- 02:xx is skipped entirely
16:45 | 03:15
17:00 | 03:30
Tidak ada satu pun event yang bisa membawa jam lokal 02 di hari itu. Dan sistem
yang cuma menyimpan string lokal bisa saja menyimpan 02:30 yang mustahil tadi —
Postgres diam-diam menggesernya maju:
SELECT (timestamp '2026-10-04 02:30' AT TIME ZONE 'Australia/Adelaide');
-- 2026-10-03 17:00:00+00 -> that instant is 03:30 local: the event moved an hour
Pelaporan per jam butuh perbaikan yang sama seperti harian, hanya saja instant-nya
kerja ekstra: resolve dan simpan jam lokal (tersimpan di local_time) saat write
untuk keperluan histogram, dan pertahankan timestamptz agar jam yang terduplikasi
dan jam yang hilang tetap bisa dibedakan.
Cara query-time yang benar — dan kenapa saya tetap tidak mengandalkannya
Jadi jawaban query-time yang "benar" adalah mengonversi tiap baris lewat zona
IANA-nya masing-masing: occurred_at AT TIME ZONE event_tz. Ini memang benar,
dan inilah baseline yang dipakai untuk mengukur semua pendekatan lain di atas.
Ada klaim yang sering diulang bahwa ekspresi ini tidak bisa di-index. Padahal
bisa — dan alasannya layak dipahami, karena semuanya berpangkal pada volatilitas
fungsi timezone() di baliknya:
SELECT pg_get_function_identity_arguments(oid) AS signature,
pg_get_function_result(oid) AS returns,
provolatile
FROM pg_proc WHERE proname = 'timezone' ORDER BY provolatile, 1;
| signature | returns | volatility |
|---|---|---|
| text, timestamp with time zone | timestamp without time zone | IMMUTABLE |
| interval, timestamp with time zone | timestamp without time zone | IMMUTABLE |
| timestamp with time zone | timestamp without time zone | STABLE |
| time with time zone | time with time zone | STABLE |
Bentuk yang dipanggil oleh occurred_at AT TIME ZONE event_tz adalah
timezone(text, timestamptz) — ditandai IMMUTABLE. Varian STABLE adalah
yang membaca setelan TimeZone sesi, bukan menerima zona sebagai argumen. Karena
bentuk zona-eksplisit ini immutable, kita bisa membangun index ekspresi di
atasnya — bahkan dengan kolom zona per-baris:
CREATE INDEX idx_expr_localdate
ON events (((occurred_at AT TIME ZONE event_tz)::date)); -- succeeds
Lalu kenapa saya tidak menjadikan konversi query-time sebagai desain utama? Ada tiga alasan, dari yang paling ringan sampai yang paling penting:
- Gampang salah saat lagi buru-buru. Ekspresi yang benar dan tiga ekspresi rusak dari awal tulisan ini kelihatan nyaris identik di code review. Orang berikutnya yang menyentuh query punya empat cara yang sama-sama masuk akal untuk menulisnya, dan cuma satu yang benar.
- Zona per-baris toh tetap harus disimpan. Konversinya hanya benar kalau tiap baris membawa zona IANA aslinya. Begitu kolom itu sudah ada, menghitung hari lokal sekali tidak menambah biaya apa pun.
IMMUTABLEdi sini sebenarnya sedikit "bohong demi kepraktisan". Aturan zona waktu memang berubah — pemerintah menambah, menghapus, dan menggeser DST. Postgres menandai fungsi ini immutable supaya bisa di-index, tapi updatetzdatabisa meninggalkan index ekspresi (atau kolom generatedSTORED) yang masih memakai aturan lama sampai AndaREINDEX. Sebaliknya, nilai yang di-resolve dan disimpan saat write membekukan tanggal lokal yang benar-benar dialami pengguna — dan itu biasanya justru jawaban yang kita inginkan.
Yang sebenarnya saya simpan: instant plus field lokal hasil resolve
Desainnya sengaja dibuat membosankan. Pertahankan timestamptz sebagai source of
truth, simpan zona IANA-nya, lalu resolve tanggal dan waktu lokal sekali saja,
saat write, ketika zona asalnya masih jelas tanpa ambiguitas:
-- written by the ingestion path, exactly once per event:
occurred_at timestamptz -- the instant; for ordering & duration math
event_tz text -- IANA zone, e.g. 'Australia/Adelaide'
local_date date -- (occurred_at AT TIME ZONE event_tz)::date
local_time time -- the wall-clock time the user saw
Kenapa date dan time, bukan string? Tipe ini tidak menyimpan timezone.
date cuma tanggal kalender dan time cuma jam dinding, jadi nilainya tetap apa
adanya di session timezone mana pun (hanya timestamptz yang bergeser). String
juga bisa, apalagi format ISO 2026-12-15 yang urutannya memang sudah benar. Tapi
kolom bertipe lebih kuat: menolak tanggal ngawur, mendukung range scan dan hitung
tanggal (day + 7, BETWEEN, ambil hari dalam minggu), lebih ringkas, dan rapi
di-index. Bahaya timezone cuma ada di timestamptz dan konversinya — tidak pernah
di date atau time.
Kalau Anda lebih suka derivasinya dipegang database ketimbang mengandalkan tiap code path, PostgreSQL 17 bisa menghitung dan menyimpannya otomatis lewat generated column — dan ini tetap jalan dengan kolom zona per-baris:
ALTER TABLE events
ADD COLUMN gen_local_date date
GENERATED ALWAYS AS ((occurred_at AT TIME ZONE event_tz)::date) STORED;
-- gen_local_date matched the app-written local_date on every row.
Ada satu detail halus yang perlu dicatat, karena Anda akan menemukan tulisan yang
bilang Postgres tidak mengizinkan ini. Yang mereka maksud sebenarnya arah
sebaliknya: menghasilkan timestamptz (konversi timestamp → timestamptz
bersandar pada setelan TimeZone sesi dan bersifat STABLE, jadi Postgres
menolaknya). Kolom kita berjalan timestamptz → date lewat zona eksplisit,
yang memanggil overload IMMUTABLE, sehingga diizinkan. Caveat tzdata yang sama
tetap berlaku: kolom generated STORED dihitung saat write dan tidak dihitung
ulang secara retroaktif ketika aturan berubah. Untuk kolom "hari apa yang dilihat
pengguna," ini justru fitur, bukan bug.
Sekarang grouping jadi sepele, gampang dibaca, dan — ini payoff praktisnya — cukup
pakai index B-tree biasa. Saya memuat 500.000 event di empat zona, meng-index
local_date, lalu membandingkannya dengan konversi query-time yang tidak punya
index pendukung:
CREATE INDEX idx_local_date ON ev_big (local_date);
-- (A) stored column
EXPLAIN ANALYZE
SELECT count(*) FROM ev_big WHERE local_date = DATE '2026-07-15';
-- (B) query-time conversion, no matching index
EXPLAIN ANALYZE
SELECT count(*) FROM ev_big WHERE (occurred_at AT TIME ZONE event_tz)::date = DATE '2026-07-15';
(A) Index Only Scan using idx_local_date
Index Cond: (local_date = '2026-07-15'::date)
Buffers: shared hit=20 read=4
Execution Time: 0.188 ms
(B) Seq Scan on ev_big
Filter: (((occurred_at AT TIME ZONE event_tz))::date = '2026-07-15'::date)
Rows Removed by Filter: 498628
Buffers: shared hit=4425
Execution Time: 106.248 ms
Jawaban sama persis, tapi terpaut ~560×. Supaya adil terhadap pendekatan query-time: menambahkan index ekspresi dari bagian sebelumnya menurunkan (B) lagi ke 0.169 ms. Tapi itu memunculkan kembali caveat immutability tadi, plus satu index ekstra yang harus diketahui keberadaannya oleh seluruh tim. Kolom tersimpan adalah versi yang langsung dipakai insinyur baru tanpa perlu diberi tahu.
Caveat yang wajib dipatuhi
Menyimpan field lokal itu denormalisasi, jadi perlakukan dengan disiplin yang biasa:
timestamptztetap jadi source of truth. Urutkan, hitung selisih, dan lakukan semua perhitungan "waktu nyata" di instant-nya. Jangan pernah mengurutkan lintas kota pakailocal_date/local_time—00:30di Jakarta dan00:30di New York itu jauh sekali jaraknya dalam waktu nyata. Field lokal hanya untuk grouping dan tampilan, bukan kronologi.- Tulis lewat satu jalur saja. Satu fungsi ingestion (atau generated column)
yang memegang derivasinya. Kalau dua code path menghitungnya dengan cara berbeda,
Anda dapat bug denormalisasi yang tidak akan pernah terungkap oleh
GROUP BYmana pun. - Jam "fall-back" DST itu benar-benar ambigu, dan itulah alasan kita menyimpan
instant. Saat Adelaide mengakhiri DST pada 2026-04-05, jam dinding bergeser
03:00 → 02:00, sehingga lokal02:30terjadi dua kali:
SELECT
(timestamp '2026-04-05 02:30' AT TIME ZONE 'Australia/Adelaide') AS what_pg_picks,
(timestamptz '2026-04-05 02:30+10:30') AS if_still_dst,
(timestamptz '2026-04-05 02:30+09:30') AS if_standard;
what_pg_picks | if_still_dst | if_standard
2026-04-04 17:00:00+00 | 2026-04-04 16:00:00+00 | 2026-04-04 17:00:00+00
Dua interpretasi ini terpaut satu jam, dan string lokal mentah tidak bisa
membedakannya — hanya timestamptz yang bisa. (Di sini Postgres me-resolve
literal ambigu ke waktu standar, tapi jangan bergantung pada pilihan itu; tangkap
instant-nya langsung dari sumber.)
Poin penting
- Sebuah "hari" — dan juga "jam" — itu milik zona, bukan milik instant.
Tentukan dulu harinya siapa sebelum Anda
GROUP BY. - Untuk bucketing, jangan pernah memakai tanggal/jam UTC, offset angka hardcode, atau singkatan zona waktu. Semuanya kacau di zona setengah jam / 45 menit dan di setiap transisi DST.
- Tampilan per jam punya dua jebakan ekstra: offset di bawah satu jam memecah satu
jam lokal ke dua bucket UTC, dan DST membuat satu hari punya jam yang dobel dan
hari lain punya jam yang hilang.
timestamptz-lah yang menjaga jam-jam itu tetap bisa dibedakan. occurred_at AT TIME ZONE '<IANA>'secara query-time dengan zona per-baris itu benar, dan — berlawanan dengan anggapan umum — ia bisa di-index.- Saya tetap me-resolve dan menyimpan
local_date/local_timesaat write: benar sejak awal, kebal terhadap perubahan aturan di kemudian hari, sargable dengan B-tree biasa, dan mustahil dibuat salah-halus oleh insinyur berikutnya. - Jaga
timestamptzdan zona IANA sebagai source of truth; field lokal adalah proyeksi yang cepat dan jujur untuk dashboard.
Bagaimana ini sejalan dengan dokumentasi
Referensi berikut menguatkan pendekatan ini sekaligus membahas lebih dalam bagian-bagian yang tak muat dalam satu tulisan:
- PostgreSQL — Date/Time Types
adalah sumber utama untuk jebakan singkatan:
EST"menetapkan UTC-5, terlepas dari apakah daylight saving secara nominal berlaku," dan offset angka konstan tidak bisa mengikuti DST. Persis seperti kegagalan di tabel New York tadi. - Tinybird — 10 best practices for timestamps and time zones
merekomendasikan menyimpan UTC plus offset, nama zona IANA, dan timestamp lokal
sebagai string — prinsip yang sama dengan menyandingkan
event_tz+local_datedi sebelahoccurred_at. Dari sini juga datang batas pra-agregasi lima belas menit. - Crunchy Data — British Columbia, Time Zones, and Postgres
adalah versi dunia-nyata dari caveat
tzdata: ketika sebuah wilayah mengubah aturannya, nilai yang diturunkan lewatAT TIME ZONEikut bergeser, dan perbaikannya adalah recompute kolom lokal tersimpan secara sengaja — bukan diam-diam mengandalkan sebuah index. - Cybertec — Time zone management in PostgreSQL
pendamping yang bagus soal bagaimana
timestamptzdan setelanTimeZonesesi saling berinteraksi. - Thread mailing-list Postgres
"shouldn't timezone(text, timestamp[tz]) be STABLE?"
adalah asal-usul klaim bahwa "
IMMUTABLEitu bohong demi kepraktisan" — volatilitas fungsinya dipertanyakan justru karena aturan zona waktu berubah, namun penandaan immutable tetap dipertahankan demi indexability.
Ini prinsip yang sama di balik menjaga referensi asli tiap sistem sumber tetap terlihat ketika Anda membangun dashboard yang menghubungkan sistem berbeda: simpan kebenaran yang presisi, dan simpan proyeksi yang mudah dibaca manusia di sebelahnya — jangan sampai query terpaksa merekonstruksinya di bawah tekanan.