Dua anak tangga terakhir normalisasi membersihkan hubungan kolom terhadap kunci: 1NF memastikan nilai atomik, 2NF memastikan ketergantungan pada kunci utuh. Anak tangga terakhir yang dibahas hampir semua buku desain database — Third Normal Form (3NF) — menuntut satu langkah lagi: kolom non-kunci tidak boleh bergantung pada kolom non-kunci lain. Pelanggarannya bernama ketergantungan transitif, dan kasusnya sangat akrab sehari-hari: kolom kota yang selalu mengikuti kode_pos. Bab ini membangun tabel pelanggan yang menyimpan kota berulang, memancing anomali konsistensinya secara nyata, lalu memperbaikinya dengan tabel wilayah yang memisahkan fakta alamat per kode pos.

Ketergantungan Transitif #

Ketergantungan transitif terjadi ketika kolom non-kunci bergantung pada kolom non-kunci lain, bukan langsung pada kunci. Pada tabel pelanggan ber-kunci id: id menentukan kode_pos (setiap pelanggan tinggal di satu alamat berkode pos tertentu), dan kode_pos menentukan kota (setiap kode pos milik satu kota). Rantai id → kode_pos → kota membuat kota hanya menempel pada tabel pelanggan secara kebetulan — rumah sejatinya adalah fakta tentang kode pos. Ringkasan klasik untuk ketiga bentuk normal sekaligus berbunyi: setiap kolom non-kunci harus bergantung pada kuncinya, kunci yang utuh, dan tidak pada apa pun selain kunci — 1NF menjaga nilai atomik, 2NF menjaga paruh “utuh”, 3NF menjaga paruh “tidak pada apa pun selain kunci”.

Tabel Pelanggar: kota Mengikuti kode pos #

Tabel pelanggan berikut menyimpan kota langsung di setiap baris — tampak praktis, dan inilah jebakannya:

sql
DROP DATABASE IF EXISTS belajar_rdb;
CREATE DATABASE belajar_rdb;
USE belajar_rdb;

CREATE TABLE pelanggan_kotor (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  jalan VARCHAR(60) NOT NULL,
  kode_pos VARCHAR(5) NOT NULL,
  kota VARCHAR(30) NOT NULL
);

INSERT INTO pelanggan_kotor (nama, jalan, kode_pos, kota) VALUES
  ('Budi Santoso', 'Jl. Merdeka 10',    '10110', 'Jakarta'),
  ('Siti Rahayu',  'Jl. Dago 45',       '40115', 'Bandung'),
  ('Andi Wijaya',  'Jl. Raya Dago 2',   '40115', 'Bandung'),
  ('Rina Melati',  'Jl. Malioboro 21',  '55111', 'Yogyakarta'),
  ('Dewi Lestari', 'Jl. Dipatiukur 8',  '40132', 'Bandung'),
  ('Tono Hartono', 'Jl. Pemuda 12',     '60119', 'Surabaya');

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 6 rows affected (0.00 sec)
Records: 6  Duplicates: 0  Warnings: 0
sql
SELECT * FROM pelanggan_kotor ORDER BY id;

Output yang Dihasilkan:

text
+----+--------------+------------------+----------+------------+
| id | nama         | jalan            | kode_pos | kota       |
+----+--------------+------------------+----------+------------+
|  1 | Budi Santoso | Jl. Merdeka 10   | 10110    | Jakarta    |
|  2 | Siti Rahayu  | Jl. Dago 45      | 40115    | Bandung    |
|  3 | Andi Wijaya  | Jl. Raya Dago 2  | 40115    | Bandung    |
|  4 | Rina Melati  | Jl. Malioboro 21 | 55111    | Yogyakarta |
|  5 | Dewi Lestari | Jl. Dipatiukur 8 | 40132    | Bandung    |
|  6 | Tono Hartono | Jl. Pemuda 12    | 60119    | Surabaya   |
+----+--------------+------------------+----------+------------+
6 rows in set (0.00 sec)

Duplikasi terlihat polos: teks Bandung tersimpan tiga kali karena tiga pelanggan beralamat di Bandung. Masalahnya bukan boros penyimpanan, melainkan fakta “kode pos 40115 bermuatan kota Bandung” yang kini hidup di banyak baris sekaligus — dan setiap salinannya berdiri sendiri, bebas berubah tanpa koordinasi dengan salinan lain.

Anomali Konsistensi: Satu kode pos, Dua kota #

Pemkot Bandung berganti nama administratif menjadi Kota Bandung, dan aplikasi kita memperbarui datanya — tetapi hanya pada satu baris, entah karena WHERE-nya terlalu sempit atau prosesnya gagal di tengah jalan:

sql
UPDATE pelanggan_kotor SET kota = 'Kota Bandung' WHERE id = 2;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
sql
SELECT id, nama, kode_pos, kota
FROM pelanggan_kotor
WHERE kode_pos = '40115'
ORDER BY id;

Output yang Dihasilkan:

text
+----+-------------+----------+--------------+
| id | nama        | kode_pos | kota         |
+----+-------------+----------+--------------+
|  2 | Siti Rahayu | 40115    | Kota Bandung |
|  3 | Andi Wijaya | 40115    | Bandung      |
+----+-------------+----------+--------------+
2 rows in set (0.00 sec)

Dua baris berkode pos 40115 kini menyimpan kota yang berbeda — Kota Bandung dan Bandung — padahal kota adalah fakta tunggal milik kode pos tersebut. Inilah anomali khas ketergantungan transitif: karena kota tersimpan di samping pelanggan, setiap pelanggan baru berkode pos 40115 juga bebas mengetik Bandung, bandung, atau salah eja apa pun; tidak ada constraint yang mengikat teks tersebut pada fakta kode posnya. Gejala insert-nya pun sama: mendaftarkan pelanggan baru berkode pos 60193 memaksa kita mengingat-ingat kota apa yang dimiliki kode pos itu, dan database tidak bisa membantu karena pemetaannya tidak tersimpan di satu tempat pun.

Perbaikan: Fakta kode pos Pindah ke Tabel wilayah #

Perbaikan 3NF memindahkan pemetaan kode pos-kota ke tabelnya sendiri. Tabel wilayah menyimpan tiap kode pos tepat satu kali beserta kotanya; tabel pelanggan cukup menyimpan kode pos sebagai penunjuk:

sql
CREATE TABLE wilayah (
  kode_pos VARCHAR(5) PRIMARY KEY,
  kota VARCHAR(30) NOT NULL
);

CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  jalan VARCHAR(60) NOT NULL,
  kode_pos VARCHAR(5) NOT NULL,
  CONSTRAINT fk_pelanggan_wilayah
    FOREIGN KEY (kode_pos) REFERENCES wilayah(kode_pos)
);

INSERT INTO wilayah (kode_pos, kota) VALUES
  ('10110', 'Jakarta'),
  ('40115', 'Bandung'),
  ('40132', 'Bandung'),
  ('55111', 'Yogyakarta'),
  ('60119', 'Surabaya');

INSERT INTO pelanggan (nama, jalan, kode_pos) VALUES
  ('Budi Santoso', 'Jl. Merdeka 10',   '10110'),
  ('Siti Rahayu',  'Jl. Dago 45',      '40115'),
  ('Andi Wijaya',  'Jl. Raya Dago 2',  '40115'),
  ('Rina Melati',  'Jl. Malioboro 21', '55111'),
  ('Dewi Lestari', 'Jl. Dipatiukur 8', '40132'),
  ('Tono Hartono', 'Jl. Pemuda 12',    '60119');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 5 rows affected (0.00 sec)
Records: 5  Duplicates: 0  Warnings: 0

Query OK, 6 rows affected (0.00 sec)
Records: 6  Duplicates: 0  Warnings: 0

Struktur perbaikannya punya dua sisi. Sisi data: pemetaan 40115 → Bandung kini hidup satu baris di wilayah, terlindung primary key dari duplikasi. Sisi penegakan: foreign key fk_pelanggan_wilayah menolak kode pos yang tidak terdaftar — pelanggan baru tidak bisa lagi menyimpan teks kota bebas, cukup kode pos yang sah. Sekarang kita ulangi skenario penggantian nama kota:

sql
UPDATE wilayah SET kota = 'Kota Bandung' WHERE kode_pos = '40115';

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

Satu baris berubah, dan fakta tersebut otomatis dipakai semua pelanggan berkode pos 40115 — mustahil membuat dua baris berkode pos sama menyimpan kota berbeda karena kotanya memang tidak lagi tersimpan per pelanggan. Laporan alamat lengkap tetap tersusun lewat JOIN:

sql
SELECT p.nama, p.jalan, p.kode_pos, w.kota
FROM pelanggan p
JOIN wilayah w ON p.kode_pos = w.kode_pos
ORDER BY p.id;

Output yang Dihasilkan:

text
+--------------+------------------+----------+--------------+
| nama         | jalan            | kode_pos | kota         |
+--------------+------------------+----------+--------------+
| Budi Santoso | Jl. Merdeka 10   | 10110    | Jakarta      |
| Siti Rahayu  | Jl. Dago 45      | 40115    | Kota Bandung |
| Andi Wijaya  | Jl. Raya Dago 2  | 40115    | Kota Bandung |
| Rina Melati  | Jl. Malioboro 21 | 55111    | Yogyakarta   |
| Dewi Lestari | Jl. Dipatiukur 8 | 40132    | Bandung      |
| Tono Hartono | Jl. Pemuda 12    | 60119    | Surabaya     |
+--------------+------------------+----------+--------------+
6 rows in set (0.00 sec)

Perbandingan sebelum/sesudahnya tegas: informasi laporan persis sama, enam baris alamat lengkap, tetapi versi bersih menyimpan tiap pemetaan kode pos-kota tepat satu kali dan menegakkan keabsahan kode pos lewat constraint. Diagram berikut memperlihatkan bentuk akhirnya — satu wilayah dimiliki banyak pelanggan:

Kapan 3NF Cukup #

Untuk sebagian besar aplikasi bisnis — toko, sistem akademik, aplikasi tugas, CRM — 3NF adalah titik berhenti yang tepat. Ketiga anomali klasik (insert, update, delete) sudah tidak punya tempat berlindung, struktur tabelnya masih masuk akal bagi orang yang membacanya, dan jumlah JOIN untuk menyusun laporan tetap terkendali. Bentuk normal yang lebih tinggi memang ada: BCNF memperketat kasus khusus kunci kandidat ganda, 4NF dan 5NF membersihkan ketergantungan bernilai banyak — tetapi kasus-kasus itu jarang muncul pada skema aplikasi biasa, dan mengejarnya bisa menghasilkan desain yang rumit tanpa manfaat nyata. Praktik umumnya: rancang sampai 3NF, lalu hanya naik lebih jauh bila pola data khusus benar-benar menuntut.

Membersihkan Database Latihan #

sql
DROP DATABASE belajar_rdb;

Output yang Dihasilkan:

text
Query OK, 3 rows affected (0.00 sec)

Referensi #

Tangga bentuk normal kini lengkap dinaiki: nilai atomik di 1NF, ketergantungan penuh pada kunci di 2NF, dan ketergantungan langsung pada kunci di 3NF. Aturan “kunci, kunci utuh, dan tidak pada selain kunci” kini menjadi refleksi desain kita. Namun ada situasi ketika aturan tersebut justru sengaja dilanggar — bukan karena ketidaktahuan, melainkan karena performa laporan yang terukur menuntutnya. Chapter berikutnya membahas denormalisasi: melanggar normalisasi dengan sadar, terukur, dan penuh risiko yang dikelola.