Grouping Data Multi-Zona Waktu di PostgreSQL Tanpa Salah

Grouping Data Multi-Zona Waktu di PostgreSQL Tanpa Salah
Satu kata diam-diam merusak dashboard multi-kota: hari. Saya membangun dashboard yang mengagregasi event dari tempat-tempat yang jamnya berbeda — venue di Adelaide, site di Kathmandu, server di New York, kantor di Jakarta. Pertanyaannya kelihatan sepele: "berapa event per hari, per kota?"
"Hari" bukan properti sebuah instant. Ia properti sebuah instant dilihat
dari satu zona waktu tertentu. Instant yang sama bisa jatuh di hari
Selasa untuk satu kota, Rabu untuk kota lain. Jadi sebelum menulis
GROUP BY ... day, kita harus menjawab dulu: harinya siapa?
Tulisan ini desain yang saya pakai di produksi, lengkap dengan SQL yang membuktikannya. Setiap tabel hasil di bawah dihasilkan langsung di PostgreSQL 17 — salin query-nya dan reproduksi sendiri. Jebakannya sama saja, mau kita mengelompokkan per hari lokal maupun per jam lokal.
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);
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. Mari bahas satu per satu.
Selisih setengah jam saja sudah cukup untuk salah hari
Adelaide bukan UTC+10. Di waktu standar offset-nya +9:30, dan saat
daylight saving — praktik memajukan jam nasional selama musim panas di
negara empat musim — 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, 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 — 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. Itu 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. Event yang baru lewat tengah malam pun terdorong mundur ke hari
sebelumnya. Pelajarannya: jangan pernah memberi singkatan atau angka ke
AT TIME ZONE. Berikan 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
Bayangkan jam UTC seperti penggaris yang goresannya tidak pas dengan
tanda jam sebuah kota. 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. 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.
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; bedanya, di
sini instant-nya memikul tugas 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. 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. Nilai yang di-resolve dan disimpan saat write, sebaliknya, 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.
timestamptzyang 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.
Artikel Terkait
Mau bangun hal serupa?
Jasa Pengembangan Backend IoT & Integrasi Multi-Protokol
Backend yang menyerap telemetri perangkat lintas MQTT, WebSocket, Modbus, dan BLE, lalu menyatukannya jadi dashboard real-time yang andal.
Lihat cara saya bisa bantu