Setiap baris di tabel butuh identitas: cara unik menunjuk baris tertentu untuk di-UPDATE, di-DELETE, atau di-JOIN. Tanpa jaminan identitas, dua baris yang isinya sama persis tidak bisa dibedakan — dan perintah “ubah data Budi” bisa mengenai baris yang keliru. Primary key adalah mekanisme PostgreSQL untuk menjamin identitas itu: satu deklarasi kecil yang sekaligus memasang tiga pengaman — nilai unik, nilai wajib ada, dan index yang membuat pencarian per identifier cepat. Chapter ini membedah ketiga jaminan tersebut lewat error nyata, lalu membahas pilihan kunci yang paling menentukan desain: natural key atau surrogate key.
Apa Itu Primary Key #
Primary key bukan tipe data — melainkan constraint yang ditempelkan pada satu kolom (atau kombinasi kolom). Satu deklarasi PRIMARY KEY memberi tiga jaminan sekaligus:
| Jaminan | Artinya | Ditegakkan lewat |
|---|---|---|
| Unik | tidak ada dua baris bernilai sama | unique index implisit |
| Tidak boleh NULL | identifier wajib terisi | not-null constraint implisit |
| Cepat dicari | lookup WHERE id = 5 memakai index | index B-tree otomatis |
Mari kita lihat ketiganya bekerja pada tabel sederhana:
-- PRIMARY KEY = unik + tidak boleh NULL + index btree otomatis
CREATE TABLE kategori (
id integer PRIMARY KEY,
nama text NOT NULL
);
INSERT INTO kategori (id, nama) VALUES
(1, 'alat tulis'),
(2, 'kertas'),
(3, 'elektronik');
SELECT id, nama FROM kategori ORDER BY id;
\d kategoriOutput yang Dihasilkan:
CREATE TABLE
INSERT 0 3
id | nama
----+------------
1 | alat tulis
2 | kertas
3 | elektronik
(3 rows)
Table "public.kategori"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
id | integer | | not null |
nama | text | | not null |
Indexes:
"kategori_pkey" PRIMARY KEY, btree (id)Tiga hal terlihat pada output tersebut:
- Kolom
idtampil sebagainot nullmeskipunCREATE TABLEtidak menuliskanNOT NULL— primary key memasangnya secara implisit. - Bagian
Indexes:memuatkategori_pkey— PostgreSQL membuat index B-tree bernama<nama_tabel>_pkeytanpa diminta. Index inilah yang membuatWHERE id = 2membaca tepat satu baris alih-alih memindai seluruh tabel. - Constraint punya nama (
kategori_pkey) yang akan muncul di pesan error — pola penamaan default yang perlu kita kenali agar pesan error mudah dibaca.
Dua Error yang Menjaga Identitas #
Kedua jaminan utama primary key bisa kita uji langsung dengan mencoba melanggarnya. Pertama, menyisipkan id yang sudah terpakai:
INSERT INTO kategori (id, nama) VALUES (2, 'alat kantor');Output yang Dihasilkan:
psql:pk-duplikat.sql:1: ERROR: duplicate key value violates unique constraint "kategori_pkey"
DETAIL: Key (id)=(2) already exists.Pesan error menyebut nama constraint (kategori_pkey) dan baris penyebabnya lewat DETAIL: Key (id)=(2) already exists — persis informasi yang dibutuhkan untuk melacak duplikasi dari log aplikasi. Kedua, menyisipkan id kosong:
INSERT INTO kategori (id, nama) VALUES (NULL, 'makanan');Output yang Dihasilkan:
psql:pk-null.sql:1: ERROR: null value in column "id" of relation "kategori" violates not-null constraint
DETAIL: Failing row contains (null, makanan).PostgreSQL menolak baris tersebut dengan pelanggaran not-null constraint pada kolom id. Perlu dicatat: menghilangkan kolom id dari daftar INSERT menghasilkan error yang sama, karena tanpa DEFAULT nilai yang tersirat adalah NULL — primary key menutup kedua celah sekaligus.
Natural Key atau Surrogate Key #
Pertanyaan desain terpenting berikutnya: kolom apa yang layak menjadi primary key? Dua aliran jawabannya:
| Aspek | Natural key | Surrogate key |
|---|---|---|
| Contoh | nik, email, kode_produk | id (identity/uuid) yang dibuat server |
| Keterbacaan | tinggi — nilainya bermakna bisnis | rendah — angka atau hash tanpa makna |
| Stabilitas | bergantung dunia nyata: orang ganti email, regulasi ganti format NIK | stabil selamanya, tidak pernah berubah |
| Ukuran index | bervariasi (email panjang, NIK 16 digit) | integer 4 byte / bigint 8 byte |
| Kebocoran informasi | bisa membocorkan data sensitif lewat URL ?nik=... | tidak membocorkan apa pun |
| Join antar tabel | panjang dan lambat bila key-nya teks | pendek dan cepat |
Praktik yang paling aman dan paling umum: surrogate key bertipe integer atau bigint GENERATED ALWAYS AS IDENTITY sebagai primary key, lalu kolom identifier dunia nyata (email, NIK, kode) dilindungi UNIQUE sebagai konstrain tambahan. Duniawi tetap unik dan bisa dicari; identitas internal tetap stabil.
Primary Key bertipe uuid #
Tipe uuid sah dijadikan primary key dan menjadi pilihan utama saat baris dibuat di banyak tempat sekaligus — aplikasi mobile yang offline, microservice yang berbeda, atau penggabungan dua database lama — karena nilainya unik tanpa perlu koordinasi:
-- Primary key bertipe uuid: identitas acak tanpa koordinasi antar server
CREATE TABLE undangan_rapat (
id uuid PRIMARY KEY,
judul text NOT NULL,
dijadwalkan_pada timestamptz NOT NULL
);
INSERT INTO undangan_rapat (id, judul, dijadwalkan_pada) VALUES
('a1b2c3d4-0000-4000-8000-000000000001', 'Rapat Mingguan',
TIMESTAMPTZ '2026-02-02 09:00:00+07'),
('a1b2c3d4-0000-4000-8000-000000000002', 'Rapat Bulanan',
TIMESTAMPTZ '2026-02-10 13:00:00+07');
SELECT id, judul, dijadwalkan_pada
FROM undangan_rapat
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
id | judul | dijadwalkan_pada
--------------------------------------+----------------+------------------------
a1b2c3d4-0000-4000-8000-000000000001 | Rapat Mingguan | 2026-02-02 09:00:00+07
a1b2c3d4-0000-4000-8000-000000000002 | Rapat Bulanan | 2026-02-10 13:00:00+07
(2 rows)Contoh di atas memakai literal uuid tetap supaya outputnya deterministik; di aplikasi nyata nilai ini dihasilkan fungsi gen_random_uuid() — lazimnya lewat DEFAULT gen_random_uuid() — dan seluruh mekanisme primary key bekerja sama persis. Trade-off-nya jujur harus disebut: uuid memakan 16 byte, dua kali bigint, sehingga index dan foreign key-nya lebih besar; dan nilainya yang acak menyebar di seluruh index B-tree, tidak serapat nomor urut. Untuk tabel satu database dengan penomoran terpusat, integer/bigint identity tetap pilihan paling hemat.
Memasang Primary Key Belakangan #
Tabel yang sudah berjalan tanpa primary key — tabel warisan sistem lama, hasil impor data — masih bisa dipasangi lewat ALTER TABLE:
-- Tabel lama tanpa primary key bisa dipasangi belakangan
CREATE TABLE rak_gudang (
kode_rak text NOT NULL,
lokasi text
);
INSERT INTO rak_gudang (kode_rak, lokasi) VALUES
('R-01', 'dekat pintu masuk'),
('R-02', 'belakang gudang');
ALTER TABLE rak_gudang ADD PRIMARY KEY (kode_rak);
\d rak_gudangOutput yang Dihasilkan:
CREATE TABLE
INSERT 0 2
ALTER TABLE
Table "public.rak_gudang"
Column | Type | Collation | Nullable | Default
----------+------+-----------+----------+---------
kode_rak | text | | not null |
lokasi | text | | |
Indexes:
"rak_gudang_pkey" PRIMARY KEY, btree (kode_rak)Output \d memperlihatkan dua efek sekaligus: index rak_gudang_pkey tercipta, dan kolom kode_rak kini not null — ADD PRIMARY KEY memasang kedua jaminan seperti versi CREATE TABLE. Perlu diingat: perintah ini memeriksa data yang sudah ada; bila kolom masih memuat duplikat, PostgreSQL menolak dengan error could not create unique index yang menyebut kunci yang bentrok, jadi data harus dibersihkan dulu.
Melihat Primary Key di Catalog #
Constraint yang kita pasang tercatat rapi di catalog PostgreSQL — pengetahuan yang berguna saat menjelajah skema milik orang lain. Dua tampilan paling sering dipakai:
-- Catalog PostgreSQL mencatat constraint dan index yang menyertainya
SELECT conname AS nama_constraint, contype AS jenis
FROM pg_constraint
WHERE conrelid = 'kategori'::regclass
ORDER BY conname;
SELECT indexname AS nama_index, indexdef AS definisi_index
FROM pg_indexes
WHERE tablename = 'kategori'
ORDER BY indexname;Output yang Dihasilkan:
nama_constraint | jenis
-----------------+-------
kategori_pkey | p
(1 row)
nama_index | definisi_index
---------------+-----------------------------------------------------------------------
kategori_pkey | CREATE UNIQUE INDEX kategori_pkey ON public.kategori USING btree (id)
(1 row)Baris kategori_pkey dengan jenis = p menandai primary key di pg_constraint (huruf p dari primary); tampilan pg_indexes kemudian membuktikan bahwa di baliknya berdiri CREATE UNIQUE INDEX ... USING btree (id) — index implisit yang dibicarakan sejak awal chapter.
Primary key menuntaskan identitas baris di dalam satu tabel. Namun tabel sungguhan tidak berdiri sendiri: produk merujuk kategori, pesanan merujuk pelanggan — dan hubungan antar tabel ini juga perlu dijaga agar tidak ada produk yang menghantam kategori yang tidak ada. Tugas itu milik foreign key, topik chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
pk-alter.sql
Unduh berkas-- Tabel lama tanpa primary key bisa dipasangi belakangan
CREATE TABLE rak_gudang (
kode_rak text NOT NULL,
lokasi text
);
INSERT INTO rak_gudang (kode_rak, lokasi) VALUES
('R-01', 'dekat pintu masuk'),
('R-02', 'belakang gudang');
ALTER TABLE rak_gudang ADD PRIMARY KEY (kode_rak);
\d rak_gudang
pk-dasar.sql
Unduh berkas-- PRIMARY KEY = unik + tidak boleh NULL + index btree otomatis
CREATE TABLE kategori (
id integer PRIMARY KEY,
nama text NOT NULL
);
INSERT INTO kategori (id, nama) VALUES
(1, 'alat tulis'),
(2, 'kertas'),
(3, 'elektronik');
SELECT id, nama FROM kategori ORDER BY id;
\d kategori
pk-duplikat.sql
Unduh berkasINSERT INTO kategori (id, nama) VALUES (2, 'alat kantor');
pk-katalog.sql
Unduh berkas-- Catalog PostgreSQL mencatat constraint dan index yang menyertainya
SELECT conname AS nama_constraint, contype AS jenis
FROM pg_constraint
WHERE conrelid = 'kategori'::regclass
ORDER BY conname;
SELECT indexname AS nama_index, indexdef AS definisi_index
FROM pg_indexes
WHERE tablename = 'kategori'
ORDER BY indexname;
pk-null.sql
Unduh berkasINSERT INTO kategori (id, nama) VALUES (NULL, 'makanan');
pk-uuid.sql
Unduh berkas-- Primary key bertipe uuid: identitas acak tanpa koordinasi antar server
CREATE TABLE undangan_rapat (
id uuid PRIMARY KEY,
judul text NOT NULL,
dijadwalkan_pada timestamptz NOT NULL
);
INSERT INTO undangan_rapat (id, judul, dijadwalkan_pada) VALUES
('a1b2c3d4-0000-4000-8000-000000000001', 'Rapat Mingguan',
TIMESTAMPTZ '2026-02-02 09:00:00+07'),
('a1b2c3d4-0000-4000-8000-000000000002', 'Rapat Bulanan',
TIMESTAMPTZ '2026-02-10 13:00:00+07');
SELECT id, judul, dijadwalkan_pada
FROM undangan_rapat
ORDER BY id;