Primary key menjaga identitas baris, tetapi data nyata punya banyak nilai yang boleh ada sekali selain identitas: satu email untuk satu akun, satu kode voucher untuk satu promo, satu kombinasi kota-kecamatan untuk satu tarif ongkir. Kalau hanya dijaga kode aplikasi, dua request yang datang bersamaan bisa sama-sama lolos pemeriksaan “email sudah dipakai?” sebelum salah satu sempat tersimpan. Constraint UNIQUE memindahkan pemeriksaan itu ke server dengan jaminan level index — dan membawa satu kejutan perilaku NULL yang wajib dipahami setiap perancang tabel. Chapter ini menguji semuanya di PostgreSQL 17, termasuk fitur modern NULLS NOT DISTINCT.
Constraint UNIQUE dan Index Otomatisnya #
UNIQUE ditempelkan langsung pada definisi kolom, atau sebagai klausa terpisah untuk kombinasi beberapa kolom. Seperti primary key, constraint ini otomatis membuat index B-tree — itulah sebabnya pengecekan duplikasi tetap cepat meski tabel berisi jutaan baris:
-- UNIQUE menjaga email tetap satu pemilik per akun
CREATE TABLE pelanggan (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
email text UNIQUE
);
INSERT INTO pelanggan (nama, email) VALUES
('Budi Santoso', '[email protected]'),
('Siti Rahayu', '[email protected]');
SELECT id, nama, email FROM pelanggan ORDER BY id;
\d pelangganOutput yang Dihasilkan:
CREATE TABLE
INSERT 0 2
id | nama | email
----+--------------+------------------
1 | Budi Santoso | [email protected]
2 | Siti Rahayu | [email protected]
(2 rows)
Table "public.pelanggan"
Column | Type | Collation | Nullable | Default
--------+--------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
nama | text | | not null |
email | text | | |
Indexes:
"pelanggan_pkey" PRIMARY KEY, btree (id)
"pelanggan_email_key" UNIQUE CONSTRAINT, btree (email)Dua baris pada bagian Indexes: memperlihatkan kerja sama dua constraint: pelanggan_pkey menjaga identitas, pelanggan_email_key (pola nama default <tabel>_<kolom>_key) menjaga keunikan email — keduanya berupa index B-tree. Mencoba mendaftarkan email yang sudah dipakai ditolak dengan error yang formatnya persis seperti pelanggaran primary key:
INSERT INTO pelanggan (nama, email)
VALUES ('Budi Kedua', '[email protected]');Output yang Dihasilkan:
psql:unique-duplikat.sql:2: ERROR: duplicate key value violates unique constraint "pelanggan_email_key"
DETAIL: Key (email)=([email protected]) already exists.Pesan duplicate key value violates unique constraint "pelanggan_email_key" menyebut nama constraint dan kunci yang bentrok — informasi yang cukup untuk aplikasi menampilkan pesan ramah seperti “email sudah terdaftar” tanpa menebak-nebak penyebabnya.
NULL yang Boleh Berulang #
Inilah kejutan klasik UNIQUE: menurut standar SQL, NULL tidak sama dengan apa pun — termasuk NULL lainnya — sehingga constraint UNIQUE tidak menganggap dua NULL sebagai duplikat:
-- Gotcha klasik: NULL tidak dianggap sama dengan NULL
INSERT INTO pelanggan (nama, email) VALUES
('Pengunjung Toko', NULL),
('Pelanggan Luring', NULL);
SELECT id, nama, email FROM pelanggan ORDER BY id;Output yang Dihasilkan:
INSERT 0 2
id | nama | email
----+------------------+------------------
1 | Budi Santoso | [email protected]
2 | Siti Rahayu | [email protected]
4 | Pengunjung Toko |
5 | Pelanggan Luring |
(4 rows)Kedua baris berhasil masuk — INSERT 0 2 — meski kolom email kini memuat dua NULL. Bila kolom ini nanti dipakai sebagai sarana login atau komunikasi, dua NULL mungkin tidak masalah (keduanya memang tanpa email); tetapi bila aturan bisnisnya “email wajib unik dan tidak boleh ada dua akun tanpa email”, perilaku default ini justru membuka celah.
NULLS NOT DISTINCT (PostgreSQL 15+) #
PostgreSQL 15 memperkenalkan opsi yang menutup celah tersebut: UNIQUE NULLS NOT DISTINCT mengubah perlakuan NULL menjadi “sama dengan NULL lainnya”, sehingga NULL kedua akan ditolak. Perilaku bawaannya sendiri bisa ditulis eksplisit sebagai NULLS DISTINCT:
-- Sejak PostgreSQL 15: NULLS NOT DISTINCT memperlakukan NULL sebagai sama
CREATE TABLE peserta (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
email text UNIQUE NULLS NOT DISTINCT
);
INSERT INTO peserta (nama, email) VALUES
('Budi Santoso', '[email protected]'),
('Tanpa Email', NULL);
SELECT id, nama, email FROM peserta ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
id | nama | email
----+--------------+------------------
1 | Budi Santoso | [email protected]
2 | Tanpa Email |
(2 rows)Baris pertama bernilai email sungguhan, baris kedua NULL — keduanya diterima karena NULL pertama tidak melanggar apa pun. Nasibnya berubah pada NULL kedua:
INSERT INTO peserta (nama, email)
VALUES ('Pendaftar Lain', NULL);Output yang Dihasilkan:
psql:unique-nulls-not-distinct-gagal.sql:2: ERROR: duplicate key value violates unique constraint "peserta_email_key"
DETAIL: Key (email)=(null) already exists.Kali ini DETAIL: Key (email)=(null) already exists — NULL diperlakukan sebagai nilai yang menduduki slot unik, dan slot itu sudah terisi. Gunakan opsi ini untuk identifier bisnis yang opsional tetapi monogam: satu baris boleh tanpa email, tetapi tidak boleh ada dua baris tanpa email sekaligus.
Unique Komposit: Keunikan Kombinasi #
UNIQUE juga bisa dideklarasikan pada kombinasi beberapa kolom — yang dijamin unik adalah pasangan nilainya, bukan masing-masing kolom. Pola ini pas untuk data seperti tarif ongkir per kecamatan: nama kecamatan boleh berulang antarkota, tetapi kombinasi kota-kecamatan harus tepat satu:
-- Unique komposit: kombinasi kota + kecamatan yang harus unik, bukan masing-masing
CREATE TABLE area_pengiriman (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kota text NOT NULL,
kecamatan text NOT NULL,
ongkir numeric(10, 2) NOT NULL,
UNIQUE (kota, kecamatan)
);
INSERT INTO area_pengiriman (kota, kecamatan, ongkir) VALUES
('Bandung', 'Coblong', 8000.00),
('Bandung', 'Sukajadi', 9000.00),
('Jakarta', 'Menteng', 15000.00);
SELECT id, kota, kecamatan, ongkir FROM area_pengiriman ORDER BY id;
\d area_pengirimanOutput yang Dihasilkan:
CREATE TABLE
INSERT 0 3
id | kota | kecamatan | ongkir
----+---------+-----------+----------
1 | Bandung | Coblong | 8000.00
2 | Bandung | Sukajadi | 9000.00
3 | Jakarta | Menteng | 15000.00
(3 rows)
Table "public.area_pengiriman"
Column | Type | Collation | Nullable | Default
-----------+---------------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
kota | text | | not null |
kecamatan | text | | not null |
ongkir | numeric(10,2) | | not null |
Indexes:
"area_pengiriman_pkey" PRIMARY KEY, btree (id)
"area_pengiriman_kota_kecamatan_key" UNIQUE CONSTRAINT, btree (kota, kecamatan)Tiga baris data menunjukkan Coblong dan Sukajadi hidup berdampingan karena kotanya sama-sama Bandung, sementara Menteng sah meski kelak ada Menteng di kota lain. Output \d memperlihatkan index komposit area_pengiriman_kota_kecamatan_key dengan kedua kolom di dalamnya; nama constraint default merangkai semua kolom yang terlibat. Praktik yang sama berlaku untuk kombinasi lain: UNIQUE (nomor_faktur, baris) pada detail faktur, atau UNIQUE (peserta_id, acara_id) pada tabel pendaftaran.
UNIQUE versus PRIMARY KEY #
Kedua constraint ini sering tertukar karena keduanya unik dan keduanya membuat index. Perbedaan strukturnya menentukan kapan masing-masing dipakai:
| Aspek | PRIMARY KEY | UNIQUE |
|---|---|---|
| Jumlah per tabel | maksimal satu | boleh lebih dari satu |
| Nilai NULL | dilarang secara otomatis (implisit NOT NULL) | diizinkan, dan bebas berulang |
| Index yang dibuat | B-tree unik | B-tree unik |
| Bisa jadi target foreign key | ya | ya (nilai unik apa pun boleh dirujuk) |
| Makna desain | identitas baris | aturan bisnis keunikan |
| Penamaan default | <tabel>_pkey | <tabel>_<kolom>_key |
Aturan mainnya sederhana: primary key adalah identitas — satu per tabel, tak boleh kosong, diutamakan surrogate key yang stabil. UNIQUE adalah aturan bisnis — email, kode voucher, kombinasi kolom — boleh ada banyak, boleh bernilai NULL, dan nilainya bermakna bagi pengguna. Tabel yang sehat biasanya memakai keduanya: id sebagai primary key plus beberapa kolom UNIQUE untuk identifier dunia nyata.
UNIQUE menjaga keunikan nilai, tetapi banyak kolom juga menuntut keberadaan nilai itu sendiri — nomor resi yang NULL tidak bisa dilacak, harga yang NULL merusak perhitungan. Kebutuhan itu ditegakkan constraint paling sederhana sekaligus paling sering dilupakan: NOT NULL, topik chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
unique-dasar.sql
Unduh berkas-- UNIQUE menjaga email tetap satu pemilik per akun
CREATE TABLE pelanggan (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
email text UNIQUE
);
INSERT INTO pelanggan (nama, email) VALUES
('Budi Santoso', '[email protected]'),
('Siti Rahayu', '[email protected]');
SELECT id, nama, email FROM pelanggan ORDER BY id;
\d pelanggan
unique-duplikat.sql
Unduh berkasINSERT INTO pelanggan (nama, email)
VALUES ('Budi Kedua', '[email protected]');
unique-komposit.sql
Unduh berkas-- Unique komposit: kombinasi kota + kecamatan yang harus unik, bukan masing-masing
CREATE TABLE area_pengiriman (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kota text NOT NULL,
kecamatan text NOT NULL,
ongkir numeric(10, 2) NOT NULL,
UNIQUE (kota, kecamatan)
);
INSERT INTO area_pengiriman (kota, kecamatan, ongkir) VALUES
('Bandung', 'Coblong', 8000.00),
('Bandung', 'Sukajadi', 9000.00),
('Jakarta', 'Menteng', 15000.00);
SELECT id, kota, kecamatan, ongkir FROM area_pengiriman ORDER BY id;
\d area_pengiriman
unique-null.sql
Unduh berkas-- Gotcha klasik: NULL tidak dianggap sama dengan NULL
INSERT INTO pelanggan (nama, email) VALUES
('Pengunjung Toko', NULL),
('Pelanggan Luring', NULL);
SELECT id, nama, email FROM pelanggan ORDER BY id;
unique-nulls-not-distinct-gagal.sql
Unduh berkasINSERT INTO peserta (nama, email)
VALUES ('Pendaftar Lain', NULL);
unique-nulls-not-distinct.sql
Unduh berkas-- Sejak PostgreSQL 15: NULLS NOT DISTINCT memperlakukan NULL sebagai sama
CREATE TABLE peserta (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
email text UNIQUE NULLS NOT DISTINCT
);
INSERT INTO peserta (nama, email) VALUES
('Budi Santoso', '[email protected]'),
('Tanpa Email', NULL);
SELECT id, nama, email FROM peserta ORDER BY id;