Data nyata hampir tidak pernah hidup di satu tabel: produk merujuk kategori, pesanan merujuk pelanggan, detail pesanan merujuk pesanan. Selama hubungan-hubungan ini hanya dijaga oleh kode aplikasi, satu bug atau satu proses manual bisa menyisakan produk yang menunjuk kategori yang sudah dihapus. Foreign key memindahkan jaminan tersebut ke server: setiap insert, update, dan delete diperiksa terhadap tabel yang dirujuk, dan pelanggaran ditolak dengan error yang jelas. Chapter ini membangun pola induk-anak lengkap dengan error nyata, lalu mengupas lima aksi referensial ON DELETE yang menentukan apa yang terjadi pada data anak saat induknya dihapus.

Pola Induk-Anak dengan REFERENCES #

Foreign key dideklarasikan pada tabel anak dengan klausa REFERENCES <tabel_induk> (<kolom>). Kolom rujukan di tabel induk harus memiliki unique constraint atau primary key — tak bisa merujuk kolom biasa:

sql fk-dasar.sql
-- Pola induk-anak: produk (anak) merujuk kategori (induk)
CREATE TABLE kategori (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE produk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama        text NOT NULL,
    harga       numeric(12, 2) NOT NULL,
    kategori_id integer REFERENCES kategori (id)
);

INSERT INTO kategori (id, nama) VALUES
    (1, 'alat tulis'),
    (2, 'kertas');

INSERT INTO produk (nama, harga, kategori_id) VALUES
    ('Buku Tulis',   4500.00, 1),
    ('Pulpen Hitam', 3000.00, 1),
    ('Kertas HVS A4', 55000.00, 2);

SELECT p.id, p.nama, p.harga, k.nama AS kategori
FROM produk p
JOIN kategori k ON k.id = p.kategori_id
ORDER BY p.id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
 id |     nama      |  harga   |  kategori  
----+---------------+----------+------------
  1 | Buku Tulis    |  4500.00 | alat tulis
  2 | Pulpen Hitam  |  3000.00 | alat tulis
  3 | Kertas HVS A4 | 55000.00 | kertas
(3 rows)

Perhatikan tiga hal dari definisi tersebut:

  1. kategori dibuat lebih dulu — tabel anak tidak bisa merujuk tabel yang belum ada; urutan CREATE TABLE mengikuti urutan ketergantungan.
  2. kategori_id integer — tipe kolom rujukan harus cocok dengan tipe kategori.id (integer); ketidakcocokan tipe membingungkan optimizer dan memicu error saat constraint dibuat.
  3. REFERENCES kategori (id) — sejak perintah ini berhasil, nilai kategori_id yang tidak ada di kategori.id tidak akan pernah lolos lagi, siapa pun yang mencoba memasukkannya.

Dua Pelanggaran yang Ditolak Server #

Foreign key menjaga dua sisi hubungan. Sisi pertama: tidak boleh ada anak yang menunjuk induk hantu:

INSERT INTO produk (nama, harga, kategori_id)
VALUES ('Pensil Kayu', 3000.00, 99);

Output yang Dihasilkan:

text
psql:fk-pelanggaran.sql:2: ERROR:  insert or update on table "produk" violates foreign key constraint "produk_kategori_id_fkey"
DETAIL:  Key (kategori_id)=(99) is not present in table "kategori".

Error tersebut muncul dari tabel anak: kategori dengan id = 99 tidak pernah ada. Bandingkan penamaannya dengan error primary key — constraint otomatis ini bernama produk_kategori_id_fkey (pola <tabel>_<kolom>_fkey), dan DETAIL-nya menyebut pasangan kunci yang gagal: Key (kategori_id)=(99) is not present in table "kategori".

Sisi kedua: induk yang masih dirujuk tidak boleh dihapus. Secara bawaan — tanpa klausa apa pun — PostgreSQL memakai aksi NO ACTION:

DELETE FROM kategori WHERE id = 1;

Output yang Dihasilkan:

text
psql:fk-hapus-induk.sql:1: ERROR:  update or delete on table "kategori" violates foreign key constraint "produk_kategori_id_fkey" on table "produk"
DETAIL:  Key (id)=(1) is still referenced from table "produk".

Kali ini error datang dari sisi induk: menghapus kategori alat tulis ditolak karena masih ada dua baris produk yang merujuknya. Perilaku bawaan ini konservatif dan aman — tidak ada data yang hilang tanpa kita putuskan secara eksplisit.

Aksi Referensial ON DELETE #

Lalu bagaimana bila kita memang ingin menghapus induk yang masih dirujuk? Klausa ON DELETE menentukan nasib baris anak:

AksiPerilaku saat induk dihapusCocok untuk
NO ACTIONtolak penghapusan (default)hubungan wajib yang tidak boleh yatim
RESTRICTtolak penghapusan, diperiksa seketikasama seperti NO ACTION, tanpa opsi tunda
CASCADEbaris anak ikut terhapuskepemilikan: detail pesanan ikut pesanannya
SET NULLkolom rujukan anak dijadikan NULLhubungan opsional: produk boleh tanpa kategori
SET DEFAULTkolom rujukan diisi nilai DEFAULT-nyajarang; default harus ada di tabel induk

NO ACTION dan RESTRICT tampak sama pada perintah tunggal, tetapi berbeda pada satu titik: NO ACTION diperiksa di akhir pernyataan sehingga bisa ditunda (deferred) sampai akhir transaksi bila constraint dideklarasikan DEFERRABLE INITIALLY DEFERRED, sedangkan RESTRICT selalu diperiksa seketika dan tidak bisa ditunda. Untuk detail deferred constraint kita bahas lebih dalam di section trigger dan transaksi; yang perlu diingat sekarang: sehari-hari keduanya sama-sama menolak.

Dua aksi lainnya paling sering dipakai dan layak dilihat aksinya. Pertama CASCADE:

sql fk-cascade.sql
-- ON DELETE CASCADE: hapus induk, anak ikut terhapus
DROP TABLE IF EXISTS produk, kategori CASCADE;

CREATE TABLE kategori (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE produk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama        text NOT NULL,
    harga       numeric(12, 2) NOT NULL,
    kategori_id integer REFERENCES kategori (id) ON DELETE CASCADE
);

INSERT INTO kategori (id, nama) VALUES
    (1, 'alat tulis'),
    (2, 'kertas');

INSERT INTO produk (nama, harga, kategori_id) VALUES
    ('Buku Tulis',    4500.00, 1),
    ('Pulpen Hitam',  3000.00, 1),
    ('Kertas HVS A4', 55000.00, 2);

DELETE FROM kategori WHERE id = 1;

SELECT id, nama, kategori_id FROM produk ORDER BY id;

Output yang Dihasilkan:

text
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
DELETE 1
 id |     nama      | kategori_id 
----+---------------+-------------
  3 | Kertas HVS A4 |           2
(1 row)

Satu DELETE pada kategori menyapu dua baris produk sekaligus — Buku Tulis dan Pulpen Hitam lenyap bersama kategorinya, dan hanya Kertas HVS A4 yang tersisa. Kedua, SET NULL:

sql fk-set-null.sql
-- ON DELETE SET NULL: hapus induk, kolom rujukan anak dikosongkan
DROP TABLE IF EXISTS produk, kategori CASCADE;

CREATE TABLE kategori (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE produk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama        text NOT NULL,
    harga       numeric(12, 2) NOT NULL,
    kategori_id integer REFERENCES kategori (id) ON DELETE SET NULL
);

INSERT INTO kategori (id, nama) VALUES
    (1, 'alat tulis'),
    (2, 'kertas');

INSERT INTO produk (nama, harga, kategori_id) VALUES
    ('Buku Tulis',    4500.00, 1),
    ('Kertas HVS A4', 55000.00, 2);

DELETE FROM kategori WHERE id = 1;

SELECT id, nama, kategori_id FROM produk ORDER BY id;

Output yang Dihasilkan:

text
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 2
DELETE 1
 id |     nama      | kategori_id 
----+---------------+-------------
  1 | Buku Tulis    |            
  2 | Kertas HVS A4 |           2
(2 rows)

Kali ini produk tetap ada, tetapi kategori_id-nya menjadi NULL — baris Buku Tulis kini berstatus “belum dikategorikan”, bukan hilang. SET NULL menuntut kolom rujukan memang boleh NULL (tanpa NOT NULL), dan aplikasi harus siap menangani nilai kosong tersebut.

Pemilihan aksi adalah keputusan bisnis: detail pesanan tanpa pesanan induk tidak bermakna (CASCADE), tetapi pesanan tanpa petugas yang memproses tetap sah (SET NULL). Bila ragu, NO ACTION bawaan memaksa kita berpikir dua kali — dan itu bukan hal buruk.

Foreign Key yang Merujuk Diri Sendiri #

Tabel juga boleh merujuk dirinya sendiri — pola standar untuk struktur hierarki seperti atasan-bawahan dalam satu tabel karyawan:

sql fk-self.sql
-- Self-referencing foreign key: struktur atasan-bawahan dalam satu tabel
CREATE TABLE karyawan (
    id        integer PRIMARY KEY,
    nama      text NOT NULL,
    jabatan   text NOT NULL,
    atasan_id integer REFERENCES karyawan (id)
);

INSERT INTO karyawan (id, nama, jabatan, atasan_id) VALUES
    (1, 'Budi Santoso', 'Manajer Toko',   NULL),
    (2, 'Siti Rahayu',  'Kepala Gudang',  1),
    (3, 'Andi Wijaya',  'Kasir',          1),
    (4, 'Dewi Lestari', 'Staf Gudang',    2);

SELECT k.nama AS karyawan, k.jabatan, COALESCE(a.nama, '-') AS atasan
FROM karyawan k
LEFT JOIN karyawan a ON a.id = k.atasan_id
ORDER BY k.id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 4
   karyawan   |    jabatan    |    atasan    
--------------+---------------+--------------
 Budi Santoso | Manajer Toko  | -
 Siti Rahayu  | Kepala Gudang | Budi Santoso
 Andi Wijaya  | Kasir         | Budi Santoso
 Dewi Lestari | Staf Gudang   | Siti Rahayu
(4 rows)

Kolom atasan_id merujuk karyawan.id pada tabel yang sama. Manajer toko berada di puncak dengan atasan_id NULL, dan self-join karyawan a mengambil nama atasan tiap baris. Constraint yang sama tetap berlaku: atasan_id yang menunjuk id yang tidak ada akan ditolak, termasuk id yang belum sempat dibuat.

Catatan Performa: Index di Kolom Foreign Key #

PostgreSQL membuat index otomatis untuk primary key dan unique constraint, tetapi tidak untuk kolom foreign key — dan ini sering mengejutkan pendatang dari database lain. Tanpa index, dua operasi mahal memindai seluruh tabel anak: DELETE/UPDATE pada tabel induk (server harus mencari semua anak yang merujuknya), dan join yang memfilter kolom rujukan. Kebiasaan yang sehat: setiap kolom FK yang aktif di-query atau induknya sering berubah layak diberi CREATE INDEX ON produk (kategori_id); — index biasa, dibuat manual.

Perhatikan juga bahwa pemeriksaan foreign key pada perintah tunggal terasa instan karena index-index inilah yang bekerja di balik layar. Di tabel besar tanpa index di kolom FK, penghapusan satu baris induk bisa berubah dari milidetik menjadi menit.

Foreign key menjaga hubungan antar tabel, dan di dalamnya ia bergantung pada dua jaminan yang sudah kita kenal: nilai rujukan harus unik dan tidak kosong. Constraint berikutnya menyempurnakan sisi “unik” itu tanpa menjadi primary key — UNIQUE, lengkap dengan perilaku NULL-nya yang mengejutkan.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

fk-cascade.sql

Unduh berkas
sql
-- ON DELETE CASCADE: hapus induk, anak ikut terhapus
DROP TABLE IF EXISTS produk, kategori CASCADE;

CREATE TABLE kategori (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE produk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama        text NOT NULL,
    harga       numeric(12, 2) NOT NULL,
    kategori_id integer REFERENCES kategori (id) ON DELETE CASCADE
);

INSERT INTO kategori (id, nama) VALUES
    (1, 'alat tulis'),
    (2, 'kertas');

INSERT INTO produk (nama, harga, kategori_id) VALUES
    ('Buku Tulis',    4500.00, 1),
    ('Pulpen Hitam',  3000.00, 1),
    ('Kertas HVS A4', 55000.00, 2);

DELETE FROM kategori WHERE id = 1;

SELECT id, nama, kategori_id FROM produk ORDER BY id;

fk-dasar.sql

Unduh berkas
sql
-- Pola induk-anak: produk (anak) merujuk kategori (induk)
CREATE TABLE kategori (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE produk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama        text NOT NULL,
    harga       numeric(12, 2) NOT NULL,
    kategori_id integer REFERENCES kategori (id)
);

INSERT INTO kategori (id, nama) VALUES
    (1, 'alat tulis'),
    (2, 'kertas');

INSERT INTO produk (nama, harga, kategori_id) VALUES
    ('Buku Tulis',   4500.00, 1),
    ('Pulpen Hitam', 3000.00, 1),
    ('Kertas HVS A4', 55000.00, 2);

SELECT p.id, p.nama, p.harga, k.nama AS kategori
FROM produk p
JOIN kategori k ON k.id = p.kategori_id
ORDER BY p.id;

fk-hapus-induk.sql

Unduh berkas
sql
DELETE FROM kategori WHERE id = 1;

fk-pelanggaran.sql

Unduh berkas
sql
INSERT INTO produk (nama, harga, kategori_id)
VALUES ('Pensil Kayu', 3000.00, 99);

fk-self.sql

Unduh berkas
sql
-- Self-referencing foreign key: struktur atasan-bawahan dalam satu tabel
CREATE TABLE karyawan (
    id        integer PRIMARY KEY,
    nama      text NOT NULL,
    jabatan   text NOT NULL,
    atasan_id integer REFERENCES karyawan (id)
);

INSERT INTO karyawan (id, nama, jabatan, atasan_id) VALUES
    (1, 'Budi Santoso', 'Manajer Toko',   NULL),
    (2, 'Siti Rahayu',  'Kepala Gudang',  1),
    (3, 'Andi Wijaya',  'Kasir',          1),
    (4, 'Dewi Lestari', 'Staf Gudang',    2);

SELECT k.nama AS karyawan, k.jabatan, COALESCE(a.nama, '-') AS atasan
FROM karyawan k
LEFT JOIN karyawan a ON a.id = k.atasan_id
ORDER BY k.id;

fk-set-null.sql

Unduh berkas
sql
-- ON DELETE SET NULL: hapus induk, kolom rujukan anak dikosongkan
DROP TABLE IF EXISTS produk, kategori CASCADE;

CREATE TABLE kategori (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE produk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama        text NOT NULL,
    harga       numeric(12, 2) NOT NULL,
    kategori_id integer REFERENCES kategori (id) ON DELETE SET NULL
);

INSERT INTO kategori (id, nama) VALUES
    (1, 'alat tulis'),
    (2, 'kertas');

INSERT INTO produk (nama, harga, kategori_id) VALUES
    ('Buku Tulis',    4500.00, 1),
    ('Kertas HVS A4', 55000.00, 2);

DELETE FROM kategori WHERE id = 1;

SELECT id, nama, kategori_id FROM produk ORDER BY id;