PostgreSQL: Kelompokkan Data per Zona Waktu
Kelompokkan data PostgreSQL per hari atau jam lokal dengan zona IANA, kolom tersimpan, dan penanganan DST serta selisih UTC pecahan.
Untuk Tim Teknis

Daftar isi
Untuk laporan per hari atau jam lokal di PostgreSQL, simpan waktu kejadian sebagai timestamptz beserta nama zona IANA, misalnya Asia/Jakarta. Tentukan zona laporan sebelum mengelompokkan data. Artikel teknis ini membandingkan konversi saat kueri dengan kolom tanggal lokal tersimpan, termasuk jebakan zona setengah jam dan perubahan jam musiman (DST).
Tanggal suatu kejadian bergantung pada zona waktu yang dipakai untuk melihatnya. Momen yang sama dapat jatuh pada Selasa di satu kota dan Rabu di kota lain. Sebelum menulis GROUP BY ... day, tentukan zona yang menjadi acuan laporan.
Rancangan berikut saya gunakan pada sistem produksi. Seluruh tabel hasil dihasilkan dengan PostgreSQL 17. Contoh memakai setelan sesi TimeZone UTC: gunakan UTC sebelum mengulang label, konversi tanggal, dan pengambilan jam UTC. Keluaran pengujian kinerja juga mengasumsikan pelaporan buffer diaktifkan. Masalah yang dibahas berlaku untuk pengelompokan per hari maupun per jam lokal.
Satu aturan: waktu kejadian itu titik waktu, bukan tanggal kalender
Simpan momen kejadian sebagai timestamptz. Tipe ini tidak menyimpan nama zona waktu. Nilainya mewakili satu titik waktu absolut, yang dapat ditampilkan menurut zona pilihan. Nilai itulah acuan pengurutan, perhitungan durasi, dan urutan kejadian.
Tanggal kalender lokal diperoleh dengan menerapkan aturan satu zona pada titik waktu tersebut. Kesalahan laporan sering berasal dari penyederhanaan konversi yang tampak wajar, seperti memakai selisih UTC tetap.
Penyederhanaan konversi yang menghasilkan tanggal keliru
Ini contoh tabel kejadian kecil. Tiap baris menyimpan titik waktu-nya
(occurred_at) dan zona IANA tempat kejadian 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
);
Contoh berikut memasukkan waktu jam lokal beserta zonanya, lalu meminta PostgreSQL menghitung titik waktu UTC. Kolom lokal disimpan bersama nilai tersebut:
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 kejadian Adelaide ke kelompok hari lokal, biasanya kita tergoda memakai salah satu dari ini:
- (a) tanggal sesi —
occurred_at::date; menjadi tanggal UTC hanya bilaTimeZonesesi adalah UTC - (b) selisih angka yang ditanam sebagai konstanta —
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 penulisan:
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 tetapi salah di Desember, untuk kota yang sama. Kelompok 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 selisih-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 — kejadian 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 selisih ke jam penuh memundurkan kejadian yang baru lewat tengah
malam sampai melompati pergantian tanggal.
Kathmandu memakai selisih UTC +5:45 tanpa DST. Contoh ini menunjukkan bahwa pembulatan ke jam penuh saja sudah cukup untuk mengubah tanggal:
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 selisih jam-penuh
yang bisa mengelompokkan zona +5:45 dengan benar — selisih aslinya
berada di antara jam. Penyederhanaan semua zona menjadi jam bulat menghilangkan informasi pada zona seperti Nepal dan Adelaide.
Banyak selisih UTC pada zona IANA masa kini berada pada kelipatan lima belas menit — Nepal
+5:45, Kepulauan Chatham +12:45. Itu kenapa panduan waktu kejadian
basis data dari Tinybird sampai di angka yang sama dari arah berbeda:
kelompok lima belas menit dapat mempertahankan batas zona masa kini tersebut untuk
pra-agregasi, sebelum sebagian zona butuh penyesuaian per-zona saat
kueri. Kalau rollup Anda per jam, zona setengah jam dan 45 menit sudah
diam-diam masuk ke kelompok yang salah.
DST membuat selisih tetap salah pada sebagian tahun
Selisih UTC yang tepat pada satu musim belum tentu berlaku sepanjang tahun. DST dapat mengubah selisih itu dua kali setahun, sehingga selisih sebuah kota tidak selalu dapat ditanam sebagai konstanta.
Gejala ini akan langsung kelihatan di dasbor — 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) tetap +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 selisih hardcode (B) terhadap grafik: ia mengarang 14 Desember yang sebenarnya nol order lokal, dan menghilangkan 16 Desember sepenuhnya. Kelompok 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 selisih tetap 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), tetapi 'EST' membeku
di -5. Kejadian 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.
Masalah tambahan pada pengelompokan per jam
Pengelompokan per jam mewarisi kesalahan tanggal di atas. Selain itu, ada dua masalah khusus: batas jam yang tidak sejajar dan jam yang berulang atau hilang saat transisi DST.
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. Pengelompokan gunakan jam UTC (atau selisih jam-penuh apa
pun), zona +5:45 atau +9:30 tidak akan pernah pas dengan batas
kelompok. Ini empat kejadian 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 |
Empat kejadian yang sebenarnya berada pada satu jam lokal terbagi menjadi batang UTC 12 dan 13. Pencarian jam tersibuk kemudian dapat memberi hasil keliru. Zona dengan selisih 30 menit, seperti Adelaide, mengalami masalah serupa.
DST fall-back: satu jam lokal menampung dua jam kejadian
Saat jam mundur, satu jam lokal terjadi dua kali. Adelaide mengakhiri
DST pada 2026-04-05 waktu lokal — titik waktu-nya 2026-04-04 16:30 UTC,
saat jam bergeser 03:00 → 02:00. Tebar satu kejadian 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 | kejadian |
|---|---|
| 1 | 4 |
| 2 | 8 |
| 3 | 4 |
Jam lokal 02 menampung dua kali lipat kejadian dibanding jam
tetangganya — dua jam nyata terhimpit jadi satu kelompok. Lonjakannya
memang nyata, tetapi 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 kejadian yang bisa membawa jam lokal 02 di hari itu.
Sistem yang hanya 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
Untuk histogram jam lokal, hitung dan simpan local_time ketika data ditulis. Pertahankan timestamptz sebagai acuan absolut agar dua kemunculan jam yang sama tetap dapat dibedakan saat DST berakhir.
Konversi saat kueri dan alasan memilih kolom tersimpan
Konversi setiap baris menggunakan zona IANA-nya sendiri: occurred_at AT TIME ZONE event_tz. Pendekatan saat kueri ini menjadi pembanding yang benar bagi strategi lain di atas.
Ada klaim yang sering diulang bahwa ekspresi ini tidak bisa diindeks.
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;
Tabel berikut memilih sebagian bentuk fungsi dan memakai nama volatilitas (i = immutable, s = stable); kueri juga menghasilkan bentuk lainnya.
| 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. Bentuk STABLE dengan satu argumen membaca TimeZone sesi. Bentuk terpisah timezone(text, timetz) juga STABLE karena konversi jam tanpa tanggal membutuhkan informasi tanggal saat ini untuk aturan zona. 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
Saya tetap memilih kolom lokal tersimpan sebagai rancangan utama karena tiga pertimbangan berikut:
- Gampang salah saat lagi buru-buru. Ekspresi yang benar dan tiga ekspresi keliru dari awal tulisan ini kelihatan nyaris identik dalam pemeriksaan kode. Orang berikutnya yang menyentuh kueri punya empat cara yang sama-sama masuk akal untuk menulisnya, dan hanya 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 menambah sedikit pekerjaan saat penulisan dan ruang penyimpanan, lalu menghindari konversi berulang saat kueri.
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 diindeks, tetapi pembaruantzdatabisa meninggalkan index ekspresi (atau kolom generatedSTORED) yang masih memakai aturan lama.REINDEXmembangun ulang indeks ekspresi; nilai kolom generated tersimpan perlu dihitung ulang secara sengaja. Nilai yang ditentukan dan disimpan saat penulisan, sebaliknya, membekukan tanggal lokal yang benar-benar dialami pengguna — dan itu biasanya justru jawaban yang kita inginkan.
Menyimpan titik waktu beserta tanggal dan jam lokal
Simpan timestamptz sebagai acuan waktu utama dan pertahankan nama zona IANA. Kemudian hitung tanggal serta jam lokal ketika data ditulis, saat zona asal kejadian masih diketahui:
-- 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
Mengapa memakai date dan time? Kedua tipe menyimpan nilai kalender lokal tanpa zona. Nilainya tetap sama meskipun zona sesi berubah. Dibanding string, tipe khusus dapat memvalidasi tanggal, mendukung pemindaian rentang dan perhitungan seperti day + 7, serta memakai indeks biasa. Konversi zona terjadi saat menurunkan nilainya dari timestamptz.
Kalau Anda lebih suka derivasinya dipegang basis data daripada mengandalkan tiap jalur kode, PostgreSQL 17 bisa menghitung dan menyimpannya otomatis lewat kolom terhitung — 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: konversi langsung timestamp → timestamptz yang membaca TimeZone sesi dan bersifat STABLE, sehingga PostgreSQL menolaknya. Sebaliknya, pemanggilan timezone(text, timestamp) dengan zona eksplisit bersifat IMMUTABLE dan dapat dipakai pada ekspresi kolom terhitung. 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 penulisan dan tidak dihitung ulang
secara retroaktif ketika aturan berubah. Untuk kolom "hari apa yang
dilihat pengguna," ini justru fitur, bukan kesalahan program.
Pengelompokan kolom tersimpan lebih mudah dibaca dan dapat memakai indeks B-tree biasa. Pada pengujian 500.000 kejadian di empat zona, saya membandingkan local_date berindeks dengan konversi saat kueri tanpa indeks pendukung:
CREATE INDEX idx_local_date ON ev_big (local_date);
-- (A) stored column
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM ev_big WHERE local_date = DATE '2026-07-15';
-- (B) query-time conversion, no matching index
EXPLAIN (ANALYZE, BUFFERS)
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
Kedua kueri menghasilkan jawaban sama, dengan selisih waktu sekitar 560× pada pengujian ini. Indeks ekspresi menurunkan waktu pendekatan (B) menjadi 0.169 ms. Jadi, perbandingan itu tidak membuktikan konversi saat kueri selalu lambat. Pilihannya juga mempertimbangkan perawatan indeks dan perubahan tzdata yang sudah dibahas.
Batas dan aturan pemeliharaan
Menyimpan kolom lokal itu denormalisasi, jadi perlakukan dengan disiplin yang biasa:
timestamptztetap jadi acuan data utama. Urutkan, hitung selisih, dan lakukan semua perhitungan "waktu nyata" di titik waktu-nya. Jangan pernah mengurutkan lintas kota menggunakanlocal_date/local_time—00:30di Jakarta dan00:30di New York itu jauh sekali jaraknya dalam waktu nyata. Kolom lokal hanya untuk pengelompokan dan tampilan, bukan kronologi.- Tulis lewat satu jalur saja. Satu fungsi penerimaan data (atau kolom
terhitung) yang memegang derivasinya. Kalau dua jalur kode menghitungnya
dengan cara berbeda, Anda dapat kesalahan program denormalisasi yang tidak akan
pernah terungkap oleh
GROUP BYmana pun. - Jam "fall-back" DST itu benar-benar ambigu, dan itulah alasan kita
menyimpan titik waktu. 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 tersebut berbeda satu jam. Nilai lokal saja tidak membedakannya, sedangkan timestamptz membedakan titik waktunya. PostgreSQL memilih waktu standar untuk literal ambigu pada contoh ini. Tangkap titik waktu dari sumber agar aplikasi tidak bergantung pada pilihan bawaan itu.
Poin penting
- Sebuah "hari" — dan juga "jam" — itu milik zona, bukan milik
titik waktu. Tentukan dulu harinya siapa sebelum Anda
GROUP BY. - Untuk pengelompokan lokal, jangan mengganti zona IANA dengan tanggal/jam UTC, selisih angka tetap, atau singkatan zona waktu. Pengelompokan UTC tetap tepat bila laporan memang didefinisikan dalam UTC.
- Tampilan per jam punya dua jebakan ekstra: selisih di bawah satu jam
memecah satu jam lokal ke dua kelompok 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. - Konversi
occurred_at AT TIME ZONE '<IANA>'saat kueri dengan zona per baris itu benar, dan — berlawanan dengan anggapan umum — ia bisa diindeks. - Saya tetap menentukan dan menyimpan
local_date/local_timesaat penulisan. Nilai yang tersimpan dipertahankan dan dapat menggunakan indeks B-tree biasa. Satu jalur perhitungan dan aturan zona laporan yang jelas tetap diperlukan. - Jaga
timestamptzdan zona IANA sebagai acuan data utama; kolom lokal adalah proyeksi yang cepat dan jujur untuk dasbor.
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 selisih 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 selisih, nama zona IANA, dan
waktu kejadian 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.
Prinsipnya sama dengan dasbor yang menghubungkan sistem berbeda: pertahankan acuan asli yang tepat, lalu simpan bentuk yang memudahkan pembacaan. Dengan begitu, laporan tidak perlu merekonstruksi konteks yang sudah diketahui saat data masuk.
Artikel Terkait
Perlu bantuan menerapkan solusi ini?
Pengembangan Sistem Operasional Bisnis
Satukan pesanan, data pelanggan, dan pekerjaan staf dalam sistem yang mengikuti alur bisnis Anda, dengan hak akses dan laporan yang jelas.
Lihat layanan terkaitCeritakan kendala operasional Anda agar kita dapat membahas langkah yang sesuai.