Tunas Akara
Kembali ke Blog

Grouping Data Multi-Zona Waktu di PostgreSQL Tanpa Salah

oleh Rayhan2026-06-2411 menit baca
postgresqlzona waktudashboarddata engineeringdstanalitik

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 UTCoccurred_at::date
  • (b) offset angka yang di-hardcodeoccurred_at AT TIME ZONE interval '9:30' (Adelaide kan "UTC+9:30"?)
  • (c) singkatan zona waktuAT 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_utcstored_local_dateiana_correctfixed_plus_0930bucket_utcwhole_hour_plus10
2026-12-14 13:452026-12-152026-12-15❌ 2026-12-14❌ 2026-12-14❌ 2026-12-14
2026-12-15 13:202026-12-152026-12-152026-12-152026-12-152026-12-15
2026-12-15 14:002026-12-162026-12-16❌ 2026-12-15❌ 2026-12-152026-12-16
2026-06-14 14:452026-06-152026-06-152026-06-15❌ 2026-06-142026-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;
labelutcround_down_p5round_up_p6iana_correct
local 2026-12-15 00:202026-12-14 18:35❌ 2026-12-142026-12-152026-12-15
local 2026-12-15 23:502026-12-15 18:052026-12-15❌ 2026-12-162026-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-141
2026-06-15111
2026-12-1411
2026-12-152222
2026-12-1611

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;
labelutcabbrev_est_fixediana_correct
summer 2026-07-05 00:30 EDT2026-07-05 04:30❌ 2026-07-042026-07-05
winter 2026-11-10 00:30 EST2026-11-10 05:302026-11-102026-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_hourlocal_hourcount
12183
13181

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_hourevents
14
28
34

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;
signaturereturnsvolatility
text, timestamp with time zonetimestamp without time zoneIMMUTABLE
interval, timestamp with time zonetimestamp without time zoneIMMUTABLE
timestamp with time zonetimestamp without time zoneSTABLE
time with time zonetime with time zoneSTABLE

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:

  1. 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.
  2. 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.
  3. IMMUTABLE di 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 update tzdata bisa meninggalkan index ekspresi (atau kolom generated STORED) yang masih memakai aturan lama sampai Anda REINDEX. 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:

  • timestamptz tetap jadi source of truth. Urutkan, hitung selisih, dan lakukan semua perhitungan "waktu nyata" di instant-nya. Jangan pernah mengurutkan lintas kota pakai local_date / local_time00:30 di Jakarta dan 00:30 di 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 BY mana 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 lokal 02:30 terjadi 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_time saat 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 timestamptz dan 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_date di sebelah occurred_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 lewat AT TIME ZONE ikut 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 timestamptz dan setelan TimeZone sesi saling berinteraksi.
  • Thread mailing-list Postgres "shouldn't timezone(text, timestamp[tz]) be STABLE?" adalah asal-usul klaim bahwa "IMMUTABLE itu 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.