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:

JaminanArtinyaDitegakkan lewat
Uniktidak ada dua baris bernilai samaunique index implisit
Tidak boleh NULLidentifier wajib terisinot-null constraint implisit
Cepat dicarilookup WHERE id = 5 memakai indexindex B-tree otomatis

Mari kita lihat ketiganya bekerja pada tabel sederhana:

sql pk-dasar.sql
-- 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

Output yang Dihasilkan:

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

  1. Kolom id tampil sebagai not null meskipun CREATE TABLE tidak menuliskan NOT NULL — primary key memasangnya secara implisit.
  2. Bagian Indexes: memuat kategori_pkey — PostgreSQL membuat index B-tree bernama <nama_tabel>_pkey tanpa diminta. Index inilah yang membuat WHERE id = 2 membaca tepat satu baris alih-alih memindai seluruh tabel.
  3. 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:

sql pk-duplikat.sql
INSERT INTO kategori (id, nama) VALUES (2, 'alat kantor');

Output yang Dihasilkan:

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

sql pk-null.sql
INSERT INTO kategori (id, nama) VALUES (NULL, 'makanan');

Output yang Dihasilkan:

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

AspekNatural keySurrogate key
Contohnik, email, kode_produkid (identity/uuid) yang dibuat server
Keterbacaantinggi — nilainya bermakna bisnisrendah — angka atau hash tanpa makna
Stabilitasbergantung dunia nyata: orang ganti email, regulasi ganti format NIKstabil selamanya, tidak pernah berubah
Ukuran indexbervariasi (email panjang, NIK 16 digit)integer 4 byte / bigint 8 byte
Kebocoran informasibisa membocorkan data sensitif lewat URL ?nik=...tidak membocorkan apa pun
Join antar tabelpanjang dan lambat bila key-nya tekspendek 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:

sql pk-uuid.sql
-- 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:

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

sql pk-alter.sql
-- 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

Output yang Dihasilkan:

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

sql pk-katalog.sql
-- 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:

text
 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
sql
-- 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
sql
-- 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 berkas
sql
INSERT INTO kategori (id, nama) VALUES (2, 'alat kantor');

pk-katalog.sql

Unduh berkas
sql
-- 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 berkas
sql
INSERT INTO kategori (id, nama) VALUES (NULL, 'makanan');

pk-uuid.sql

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