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 pelanggan

Output yang Dihasilkan:

text
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:

text
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:

sql unique-null.sql
-- 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:

text
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:

text
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:

text
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_pengiriman

Output yang Dihasilkan:

text
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:

AspekPRIMARY KEYUNIQUE
Jumlah per tabelmaksimal satuboleh lebih dari satu
Nilai NULLdilarang secara otomatis (implisit NOT NULL)diizinkan, dan bebas berulang
Index yang dibuatB-tree unikB-tree unik
Bisa jadi target foreign keyyaya (nilai unik apa pun boleh dirujuk)
Makna desainidentitas barisaturan 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
sql
-- 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-komposit.sql

Unduh berkas
sql
-- 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
sql
-- 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 berkas
sql
INSERT INTO peserta (nama, email)
VALUES ('Pendaftar Lain', NULL);

unique-nulls-not-distinct.sql

Unduh berkas
sql
-- 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;