Banyak data bisnis berbentuk rentang: kamar hotel dipesan dari 1 sampai 5 Maret, harga promo berlaku sepanjang kuartal pertama, langganan aktif dari tanggal registrasi tanpa batas akhir. Pola lama menyimpannya sebagai dua kolom — mulai dan selesai — lalu memeriksa tumpang tindih dengan kondisi WHERE a.mulai < b.selesai AND b.mulai < a.selesai yang mudah salah tulis dan tidak dilindungi index. PostgreSQL menawarkan cara yang lebih aman: tipe range menyimpan seluruh interval sebagai satu nilai, dan tipe multirange menyimpan kumpulan interval sebagai satu nilai. Pada chapter ini kita bedah keduanya, lalu menutupnya dengan studi kasus reservasi kamar yang menolak jadwal bentrok di level database.
Rentangan Nilai dalam Satu Kolom #
Sebuah nilai range memuat batas bawah, batas atas, dan informasi apakah tiap batas ikut dihitung. PostgreSQL menyediakan enam range bawaan:
| Tipe | Rentangan | Contoh nilai |
|---|---|---|
int4range | bilangan bulat integer | [1,10) |
int8range | bilangan bulat bigint | [1000000,2000000) |
numrange | bilangan desimal numeric | [1.5,2.5) |
daterange | tanggal date tanpa jam | [2026-01-01,2026-03-31) |
tsrange | timestamp tanpa zona waktu | [2026-03-01 08:00,2026-03-01 10:00) |
tstzrange | timestamp dengan zona waktu | [2026-03-01 08:00+07,10:00+07) |
Dua tipe terakhir penting untuk jadwal berdurasi jam — rapat, sewa studio, kunjungan servis — sedangkan daterange pas untuk sewa harian dan masa berlaku harga. Bila enam tipe ini belum cukup, Anda bisa membuat range sendiri lewat CREATE TYPE ... AS RANGE di atas tipe dasar pilihan; kebutuhan itu baru muncul di aplikasi dengan domain khusus, misalnya rentang alamat IP.
Konstruksi: Kurung Siku dan Batas #
Notasi range memakai kurung siku dan kurung biasa untuk menandai batas inklusif (ikut dihitung) dan eksklusif (tidak ikut dihitung):
| Notasi | Batas bawah | Batas atas | Keterangan |
|---|---|---|---|
[a,b] | inklusif | inklusif | kedua ujung ikut |
[a,b) | inklusif | eksklusif | bentuk paling umum |
(a,b) | eksklusif | eksklusif | kedua ujung keluar |
(a,b] | eksklusif | inklusif | jarang dipakai |
[a,) | inklusif | tanpa batas | dari a selamanya |
empty | — | — | rentang tanpa anggota |
Mari kita lihat perilakunya langsung:
-- Konstruktor fungsi: batas bawah inklusif, batas atas eksklusif
SELECT int4range(1, 10) AS standar;
-- Teks batas '[]' membuat kedua batas ikut inklusif
SELECT int4range(1, 10, '[]') AS keduanya_inklusif;
-- Literal rentang tanggal: [bawah, atas)
SELECT '[2026-01-01,2026-03-31)'::daterange AS kuartal_pertama;
-- Batas atas inklusif dinormalkan server untuk tipe diskret
SELECT '[2026-01-01,2026-03-31]'::daterange AS ditulis_inklusif;
-- Rentang kosong yang sah
SELECT 'empty'::int4range AS kosong;
-- Batas atas terbuka: dari 3 ke atas tanpa batas
SELECT '[3,)'::int4range AS tanpa_batas_atas;Output yang Dihasilkan:
standar
---------
[1,10)
(1 row)
keduanya_inklusif
-------------------
[1,11)
(1 row)
kuartal_pertama
-------------------------
[2026-01-01,2026-03-31)
(1 row)
ditulis_inklusif
-------------------------
[2026-01-01,2026-04-01)
(1 row)
kosong
--------
empty
(1 row)
tanpa_batas_atas
------------------
[3,)
(1 row)Dua perilaku penting muncul dari hasil tersebut. Pertama, konstruktor int4range(1, 10) menghasilkan [1,10) — konvensi bawaannya memakai batas bawah inklusif dan batas atas eksklusif, pola yang membuat rentang berturutan seperti [1,5) dan [5,10) bisa disambung tanpa celah dan tanpa tumpang tindih. Kedua, server menormalkan nilai ke bentuk kanonik: rentang yang ditulis inklusif-inklusif [2026-01-01,2026-03-31] disimpan menjadi [2026-01-01,2026-04-01) — batas atasnya digeser ke 1 April eksklusif, yang bermakna persis sama untuk tanggal diskret. Nilai empty adalah anggota tipe yang sah, bukan NULL: kolom berisi empty menyatakan “rentang tanpa anggota”, sedangkan NULL menyatakan “nilai tidak diketahui”.
Membedah dan Menguji Rentang #
Sejumlah fungsi mengupas isi rentang, dan sejumlah operator menguji hubungan antar rentang. Keduanya paling gampang dilihat sekali jalan:
| Operator | Pertanyaan yang dijawab |
|---|---|
@> / <@ | apakah rentang mengandung nilai/rentang lain? |
&& | apakah dua rentang tumpang tindih? |
| `- | -` |
+ | gabungan (union) dua rentang bertetangga |
* | irisan (intersection) dua rentang |
-- Membedah batas sebuah rentang
SELECT lower('[2026-01-01,2026-03-31)'::daterange) AS batas_bawah,
upper('[2026-01-01,2026-03-31)'::daterange) AS batas_atas,
lower_inc('[2026-01-01,2026-03-31)'::daterange) AS bawah_inklusif,
upper_inc('[2026-01-01,2026-03-31)'::daterange) AS atas_inklusif;
-- Rentang kosong punya batas NULL
SELECT isempty('empty'::int4range) AS kosong,
lower('empty'::int4range) AS batasnya_null;
-- Mengandung nilai @> : tanggal tunggal
SELECT '[2026-01-01,2026-03-31)'::daterange @> DATE '2026-02-15' AS februari_masuk,
'[2026-01-01,2026-03-31)'::daterange @> DATE '2026-04-15' AS april_masuk;
-- Tumpang tindih && : apakah dua rentang bersinggungan?
SELECT '[2026-01-01,2026-03-31)'::daterange && '[2026-03-01,2026-05-01)'::daterange AS maret_bentrok,
'[2026-01-01,2026-02-01)'::daterange && '[2026-02-01,2026-03-01)'::daterange AS tepi_bersentuhan;
-- Bersebelahan -|- : bersentuhan tapi tidak tumpang tindih
SELECT int4range(1, 5) -|- int4range(5, 10) AS bersebelahan,
int4range(1, 5) -|- int4range(6, 10) AS terpisah_satu_langkah;Output yang Dihasilkan:
batas_bawah | batas_atas | bawah_inklusif | atas_inklusif
-------------+------------+----------------+---------------
2026-01-01 | 2026-03-31 | t | f
(1 row)
kosong | batasnya_null
--------+---------------
t |
(1 row)
februari_masuk | april_masuk
----------------+-------------
t | f
(1 row)
maret_bentrok | tepi_bersentuhan
---------------+------------------
t | f
(1 row)
bersebelahan | terpisah_satu_langkah
--------------+-----------------------
t | f
(1 row)Empat pengamatan dari hasil di atas. Fungsi lower() dan upper() mengembalikan batas sebagai tipe dasarnya (tanggal), sementara lower_inc() dan upper_inc() mengembalikan boolean. Rentang empty melaporkan batas NULL — cek dengan isempty(), bukan IS NULL. Operator && menghasilkan f pada kolom tepi_bersentuhan karena dua rentang yang berbagi tepi eksklusif tidak dianggap tumpang tindih — justru kenyataan inilah yang membuat penjadwalan bersih. Operator -|- melengkapi keduanya: [1,5) dan [5,10) bernilai bersebelahan karena menempel sempurna.
Studi Kasus: Reservasi Kamar Tanpa Bentrok #
Inilah demo yang menunjukkan kekuatan penuh range type. Tabel reservasi menyimpan periode menginap sebagai daterange, lalu constraint EXCLUDE memaksa database menolak dua reservasi yang tumpang tindih untuk kamar yang sama:
-- btree_gist membuat kolom teks bisa ikut constraint exclusion ber-index GiST
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE reservasi (
id integer PRIMARY KEY,
kamar text NOT NULL,
periode daterange NOT NULL
);
ALTER TABLE reservasi
ADD CONSTRAINT tanpa_benturan
EXCLUDE USING gist (kamar WITH =, periode WITH &&);
INSERT INTO reservasi (id, kamar, periode) VALUES
(1, 'A-101', daterange('2026-03-01', '2026-03-05', '[)')),
(2, 'A-102', daterange('2026-03-02', '2026-03-04', '[)')),
(3, 'A-101', daterange('2026-03-05', '2026-03-08', '[)'));
-- Reservasi mana di kamar A-101 yang bentrok dengan permintaan 3-6 Maret?
SELECT id, kamar, periode
FROM reservasi
WHERE kamar = 'A-101'
AND periode && daterange('2026-03-03', '2026-03-06', '[)')
ORDER BY id;Output yang Dihasilkan:
CREATE EXTENSION
CREATE TABLE
ALTER TABLE
INSERT 0 3
id | kamar | periode
----+-------+-------------------------
1 | A-101 | [2026-03-01,2026-03-05)
3 | A-101 | [2026-03-05,2026-03-08)
(2 rows)Mari kita bedah file ini baris demi baris:
CREATE EXTENSION btree_gist: constraintEXCLUDE USING gistmembutuhkan operator kelas GiST untuk semua kolomnya;btree_gistmenyediakan itu untuk tipe-tipe biasa sepertitext, sehinggakamar WITH =bisa ikut serta.EXCLUDE USING gist (kamar WITH =, periode WITH &&): aturannya berbunyi “tidak boleh ada dua baris yang kamar-nya sama DAN periode-nya tumpang tindih”. Ini generalisasiUNIQUE—UNIQUEmenolak kesamaan,EXCLUDEmenolak kombinasi operator apa pun yang kita tentukan.- Tiga
INSERTsukses: reservasi 1 dan 3 sama-sama kamar A-101 tapi periodenya[03-01,03-05)dan[03-05,03-08)hanya bersentuhan di tepi — tidak tumpang tindih, jadi sah. Reservasi 2 kamar lain, bebas. - Query bentrok: satu kondisi
periode && daterange(...)menggantikan perbandingan empat kolom dua-arah — dan karena kolomnya terindeks GiST lewat constraint, query bentrok seperti ini cepat di tabel besar.
Benturan Ditolak Database #
Saatnya membuktikan jaminannya. Pemesanan kamar A-101 untuk 4-8 Maret tumpang tindih dengan reservasi id 1 — dan server menolaknya:
INSERT INTO reservasi (id, kamar, periode)
VALUES (4, 'A-101', daterange('2026-03-04', '2026-03-08', '[)'));Output yang Dihasilkan:
psql:benturan.sql:2: ERROR: conflicting key value violates exclusion constraint "tanpa_benturan"
DETAIL: Key (kamar, periode)=(A-101, [2026-03-04,2026-03-08)) conflicts with existing key (kamar, periode)=(A-101, [2026-03-01,2026-03-05)).Pesan error menyebut nama constraint tanpa_benturan dan mendetailkan pasangan kunci yang bentrok: periode baru [2026-03-04,2026-03-08) menabrak reservasi lama [2026-03-01,2026-03-05). Perhatikan bahwa hanya bagian 4-5 Maret yang bertabrakan, tetapi server langsung menolak seluruh barisnya — tidak ada setengah reservasi yang tersimpan. Aplikasi cukup menangkap error ini dan menyarankan tanggal lain ke pengguna.
Multirange: Kumpulan Rentang dalam Satu Nilai #
Kadang satu entitas memang punya banyak interval: musim promo yang terdiri dari periode Januari dan Juli, jadwal dokter yang bolong di tengah, hari libur nasional sepanjang tahun. Sebelum PostgreSQL 14 itu harus disimpan sebagai baris-baris terpisah; sekarang tipe multirange menampung semuanya dalam satu nilai. Setiap range type punya pasangan multirange-nya — int4multirange, datemultirange, tstzmultirange, dan seterusnya:
-- Musim promo toko: dua periode dalam satu nilai multirange
SELECT '{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange AS musim_promo;
-- Multirange mengandung tanggal tunggal
SELECT '{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange @> DATE '2026-01-10' AS januari_masuk,
'{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange @> DATE '2026-03-10' AS maret_masuk;
-- Tumpang tindih dengan rentang biasa
SELECT '{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange && daterange('2026-01-14', '2026-01-20', '[)') AS bersinggungan;
-- Bongkar multirange menjadi baris-baris rentang biasa
SELECT unnest('{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange) AS periode_promo;
-- Pembungkus terkecil dua rentang
SELECT range_merge(int4range(1, 5), int4range(4, 12)) AS pembungkus;Output yang Dihasilkan:
musim_promo
---------------------------------------------------
{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}
(1 row)
januari_masuk | maret_masuk
---------------+-------------
t | f
(1 row)
bersinggungan
---------------
t
(1 row)
periode_promo
-------------------------
[2026-01-01,2026-01-15)
[2026-07-01,2026-07-10)
(2 rows)
pembungkus
------------
[1,12)
(1 row)Notasi literalnya membungkus daftar rentang dalam kurung kurawal: {[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}. Operator-operator lama bekerja langsung: @> memeriksa tanggal 10 Januari masuk periode promo (benar) sedangkan 10 Maret tidak (bernilai salah, tanggal itu di antara dua periode). Fungsi unnest() memecah multirange menjadi baris-baris rentang biasa — jembatan praktis bila analisis butuh bentuk tabel, dan range_merge() mengembalikan rentang tunggal terkecil yang membungkus kedua inputnya. Dalam desain tabel, kolom multirange cocok untuk pola “jadwal menggantung” di mana intervalnya sering diubah sekaligus, misalnya tabel hari kerja efektif gudang sepanjang tahun.
Kita sudah menyelesaikan katalog tipe data PostgreSQL — dari numerik, teks, waktu, identifier, sampai biner, jaringan, geometri, dan rentang. Satu pertanyaan tersisa dan paling sering ditanyakan: di antara semua itu, tipe mana yang tepat untuk setiap kolom? Chapter penutup section ini merangkumnya menjadi tabel keputusan yang siap dipakai saat mendesain tabel: Memilih Tipe Data.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
benturan.sql
Unduh berkasINSERT INTO reservasi (id, kamar, periode)
VALUES (4, 'A-101', daterange('2026-03-04', '2026-03-08', '[)'));
konstruksi.sql
Unduh berkas-- Konstruktor fungsi: batas bawah inklusif, batas atas eksklusif
SELECT int4range(1, 10) AS standar;
-- Teks batas '[]' membuat kedua batas ikut inklusif
SELECT int4range(1, 10, '[]') AS keduanya_inklusif;
-- Literal rentang tanggal: [bawah, atas)
SELECT '[2026-01-01,2026-03-31)'::daterange AS kuartal_pertama;
-- Batas atas inklusif dinormalkan server untuk tipe diskret
SELECT '[2026-01-01,2026-03-31]'::daterange AS ditulis_inklusif;
-- Rentang kosong yang sah
SELECT 'empty'::int4range AS kosong;
-- Batas atas terbuka: dari 3 ke atas tanpa batas
SELECT '[3,)'::int4range AS tanpa_batas_atas;
multirange.sql
Unduh berkas-- Musim promo toko: dua periode dalam satu nilai multirange
SELECT '{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange AS musim_promo;
-- Multirange mengandung tanggal tunggal
SELECT '{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange @> DATE '2026-01-10' AS januari_masuk,
'{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange @> DATE '2026-03-10' AS maret_masuk;
-- Tumpang tindih dengan rentang biasa
SELECT '{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange && daterange('2026-01-14', '2026-01-20', '[)') AS bersinggungan;
-- Bongkar multirange menjadi baris-baris rentang biasa
SELECT unnest('{[2026-01-01,2026-01-15),[2026-07-01,2026-07-10)}'::datemultirange) AS periode_promo;
-- Pembungkus terkecil dua rentang
SELECT range_merge(int4range(1, 5), int4range(4, 12)) AS pembungkus;
operator.sql
Unduh berkas-- Membedah batas sebuah rentang
SELECT lower('[2026-01-01,2026-03-31)'::daterange) AS batas_bawah,
upper('[2026-01-01,2026-03-31)'::daterange) AS batas_atas,
lower_inc('[2026-01-01,2026-03-31)'::daterange) AS bawah_inklusif,
upper_inc('[2026-01-01,2026-03-31)'::daterange) AS atas_inklusif;
-- Rentang kosong punya batas NULL
SELECT isempty('empty'::int4range) AS kosong,
lower('empty'::int4range) AS batasnya_null;
-- Mengandung nilai @> : tanggal tunggal
SELECT '[2026-01-01,2026-03-31)'::daterange @> DATE '2026-02-15' AS februari_masuk,
'[2026-01-01,2026-03-31)'::daterange @> DATE '2026-04-15' AS april_masuk;
-- Tumpang tindih && : apakah dua rentang bersinggungan?
SELECT '[2026-01-01,2026-03-31)'::daterange && '[2026-03-01,2026-05-01)'::daterange AS maret_bentrok,
'[2026-01-01,2026-02-01)'::daterange && '[2026-02-01,2026-03-01)'::daterange AS tepi_bersentuhan;
-- Bersebelahan -|- : bersentuhan tapi tidak tumpang tindih
SELECT int4range(1, 5) -|- int4range(5, 10) AS bersebelahan,
int4range(1, 5) -|- int4range(6, 10) AS terpisah_satu_langkah;
tabel-reservasi.sql
Unduh berkas-- btree_gist membuat kolom teks bisa ikut constraint exclusion ber-index GiST
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE reservasi (
id integer PRIMARY KEY,
kamar text NOT NULL,
periode daterange NOT NULL
);
ALTER TABLE reservasi
ADD CONSTRAINT tanpa_benturan
EXCLUDE USING gist (kamar WITH =, periode WITH &&);
INSERT INTO reservasi (id, kamar, periode) VALUES
(1, 'A-101', daterange('2026-03-01', '2026-03-05', '[)')),
(2, 'A-102', daterange('2026-03-02', '2026-03-04', '[)')),
(3, 'A-101', daterange('2026-03-05', '2026-03-08', '[)'));
-- Reservasi mana di kamar A-101 yang bentrok dengan permintaan 3-6 Maret?
SELECT id, kamar, periode
FROM reservasi
WHERE kamar = 'A-101'
AND periode && daterange('2026-03-03', '2026-03-06', '[)')
ORDER BY id;