Chapter First Normal Form
menuntaskan urusan isi sel: tidak ada daftar koma, tidak ada kolom berulang. Pemilik Toko Maju Jaya kini merasa sudah berhati-hati — tabel transaksi April ditulis dengan primary key composite (nomor_pesanan, kode_produk), setiap sel bernilai tunggal, dan kolom-kolomnya punya nama yang masuk akal. Tabel itu lolos 1NF dengan mulus, tetapi tetap menyimpan fakta yang sama berkali-kali, dan anomali update masih hidup di dalamnya. Pelakunya bukan isi sel, melainkan cara kolom bersandar pada kunci: sebagian kolom hanya bergantung pada separuh kunci. Second Normal Form (2NF) melarang persis ketergantungan parsial itu, dan chapter ini membedahnya sampai tuntas pada tabel yang sama.
Definisi: Kunci Harus Utuh #
2NF didefinisikan berjenjang: sebuah tabel berada dalam 2NF bila tabel itu sudah 1NF dan setiap atribut non-kunci bergantung pada kunci secara utuh — bukan pada potongannya. Pada tabel dengan primary key satu kolom, syarat ini otomatis terpenuhi karena kuncinya tidak bisa dipotong. 2NF baru berbicara serius ketika kuncinya composite, seperti (nomor_pesanan, kode_produk):
| Bentuk ketergantungan | Contoh pada tabel kita | Vonis |
|---|---|---|
| bergantung pada kunci utuh | kuantitas bergantung pada pasangan pesanan-produk | sah |
| bergantung pada separuh kunci | nama_produk bergantung pada kode_produk saja | parsial — pelanggaran 2NF |
| bergantung pada separuh kunci | tanggal bergantung pada nomor_pesanan saja | parsial — pelanggaran 2NF |
Kata kuncinya adalah “utuh”. Fakta “pesanan 501 membeli 10 unit Buku Tulis” memang milik pasangan (501, P01) — ganti salah satu anggotanya, fakta itu berubah. Sebaliknya, fakta “P01 bernama Buku Tulis” tetap sama di pesanan mana pun, dan fakta “pesanan 501 terjadi 2 April” tetap sama produk apa pun yang dibeli. Fakta yang tidak peduli pada separuh kunci tidak berhak tinggal di tabel berkunci composite itu.
Meja Peraga: Tabel yang Lolos 1NF #
Tabel transaksi April memang tertata: setiap sel satu nilai, kunci composite dideklarasikan, dan tidak ada kolom bernomor. Dua query diagnosis di akhir file adalah alat ukurnya:
-- Tabel transaksi April Toko Maju Jaya: setiap sel satu nilai,
-- primary key composite dideklarasikan dengan sadar -- tabel ini
-- lolos 1NF, tetapi kolom non-kuncinya bersandar pada separuh kunci.
CREATE TABLE pesanan_detail (
nomor_pesanan integer NOT NULL,
kode_produk text NOT NULL,
tanggal date NOT NULL,
nama_pelanggan text NOT NULL,
nama_produk text NOT NULL,
harga numeric(12, 2) NOT NULL,
kuantitas integer NOT NULL,
PRIMARY KEY (nomor_pesanan, kode_produk)
);
INSERT INTO pesanan_detail (nomor_pesanan, kode_produk, tanggal,
nama_pelanggan, nama_produk, harga, kuantitas) VALUES
(501, 'P01', DATE '2026-04-02', 'Budi Santoso', 'Buku Tulis', 4500.00, 10),
(501, 'P02', DATE '2026-04-02', 'Budi Santoso', 'Pulpen Hitam', 3000.00, 5),
(502, 'P01', DATE '2026-04-08', 'Siti Rahayu', 'Buku Tulis', 4500.00, 2);
SELECT nomor_pesanan, kode_produk, tanggal, nama_pelanggan,
nama_produk, harga, kuantitas
FROM pesanan_detail
ORDER BY nomor_pesanan, kode_produk;
-- Diagnosis sisi produk: fakta "P01 bernama Buku Tulis, harga 4500.00"
-- tersimpan di dua baris, karena hanya bergantung pada kode_produk.
SELECT kode_produk, nama_produk, harga, count(*) AS baris_penyimpan
FROM pesanan_detail
GROUP BY kode_produk, nama_produk, harga
ORDER BY kode_produk;
-- Diagnosis sisi pesanan: fakta "501 terjadi 2026-04-02 atas nama
-- Budi Santoso" juga tersimpan dua kali, karena hanya bergantung
-- pada nomor_pesanan.
SELECT nomor_pesanan, tanggal, nama_pelanggan, count(*) AS baris_penyimpan
FROM pesanan_detail
GROUP BY nomor_pesanan, tanggal, nama_pelanggan
ORDER BY nomor_pesanan;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
nomor_pesanan | kode_produk | tanggal | nama_pelanggan | nama_produk | harga | kuantitas
---------------+-------------+------------+----------------+--------------+---------+-----------
501 | P01 | 2026-04-02 | Budi Santoso | Buku Tulis | 4500.00 | 10
501 | P02 | 2026-04-02 | Budi Santoso | Pulpen Hitam | 3000.00 | 5
502 | P01 | 2026-04-08 | Siti Rahayu | Buku Tulis | 4500.00 | 2
(3 rows)
kode_produk | nama_produk | harga | baris_penyimpan
-------------+--------------+---------+-----------------
P01 | Buku Tulis | 4500.00 | 2
P02 | Pulpen Hitam | 3000.00 | 1
(2 rows)
nomor_pesanan | tanggal | nama_pelanggan | baris_penyimpan
---------------+------------+----------------+-----------------
501 | 2026-04-02 | Budi Santoso | 2
502 | 2026-04-08 | Siti Rahayu | 1
(2 rows)Query diagnosis pertama memetakan sisi produk: kolom baris_penyimpan bernilai 2 untuk P01, artinya nama dan harga Buku Tulis hidup di dua baris. Query kedua memetakan sisi pesanan: tanggal dan nama Budi Santoso untuk pesanan 501 juga hidup di dua baris. Hanya kuantitas yang tidak pernah dihitung ganda — nilainya memang berbeda pada tiap pasangan, tanda bahwa kuantitas bergantung pada kunci secara utuh. Pola ini adalah cara cepat mencurigai pelanggaran 2NF tanpa membaca definisi mana pun: atribut non-kunci yang bernilai sama berulang kali pada satu nilai kunci parsial sedang bersandar pada separuh kunci.
Anomali yang Ditimbulkan #
Gejalanya muncul pada operasi paling wajar: koreksi tanggal. Pesanan 501 ternyata terjadi 3 April, bukan 2 April. Tanggal adalah fakta milik pesanan, tetapi rumahnya di tabel item — sehingga UPDATE yang mengubahnya terpaksa menyebut item tertentu, dan satu item yang terlupakan langsung membelah fakta:
-- Koreksi tanggal: pesanan 501 ternyata terjadi 2026-04-03, bukan 2026-04-02.
-- Tanggal adalah fakta milik PESANAN, tetapi rumahnya di tabel item --
-- sehingga petugas harus menyebut item tertentu saat mengubahnya.
UPDATE pesanan_detail SET tanggal = DATE '2026-04-03'
WHERE nomor_pesanan = 501 AND kode_produk = 'P02';
-- Pesanan yang sama kini punya dua tanggal: baris P01 masih 04-02,
-- baris P02 sudah 04-03.
SELECT nomor_pesanan, kode_produk, tanggal
FROM pesanan_detail
WHERE nomor_pesanan = 501
ORDER BY kode_produk;
-- Diterjemahkan menjadi query pemeriksa: satu pesanan, dua versi tanggal.
SELECT nomor_pesanan, count(DISTINCT tanggal) AS versi_tanggal
FROM pesanan_detail
GROUP BY nomor_pesanan
ORDER BY nomor_pesanan;Output yang Dihasilkan:
UPDATE 1
nomor_pesanan | kode_produk | tanggal
---------------+-------------+------------
501 | P01 | 2026-04-02
501 | P02 | 2026-04-03
(2 rows)
nomor_pesanan | versi_tanggal
---------------+---------------
501 | 2
502 | 1
(2 rows)Query pertama memperlihatkan belahannya secara harfiah: pesanan 501 baris P01 masih bertanggal 2026-04-02 sementara baris P02 sudah 2026-04-03. Query kedua merangkumnya — versi_tanggal bernilai 2 untuk pesanan 501, padahal sebuah pesanan hanya terjadi sekali. Ini kerabat dekat anomali update di chapter Normalisasi Database
, tetapi diagnosisnya kini lebih tajam: masalahnya bukan sekadar redundansi, melainkan ketergantungan parsial — tanggal bersandar pada nomor_pesanan, jadi jumlah baris yang harus disentuh mengikuti jumlah item dalam pesanan, bukan jumlah fakta yang berubah. Sisi produk menderita penyakit yang sama ke arah sebaliknya: mengganti nama atau harga sebuah produk harus menyapu semua baris yang memuat produk itu, sebanyak apa pun pesanannya.
Dekomposisi Menjadi Tiga Tabel #
Perbaikan 2NF adalah memisahkan setiap kelompok ketergantungan ke tabelnya sendiri. Fakta pesanan pindah ke pesanan dengan kunci nomor_pesanan; fakta produk pindah ke produk dengan kunci kode_produk; yang tersisa di tabel pasangan hanyalah kuantitas — satu-satunya kolom yang memang bergantung pada kunci utuh:
-- Langkah 0: rekonsiliasi data sebelum migrasi.
-- Tanggal yang benar untuk seluruh pesanan 501 adalah 2026-04-03
-- (koreksi di file sebelumnya memang benar -- yang salah adalah bentuknya:
-- hanya satu baris yang tersentuh).
UPDATE pesanan_detail SET tanggal = DATE '2026-04-03'
WHERE nomor_pesanan = 501;
-- Langkah 1: tiga tabel dengan pembagian kerja yang jelas.
-- Fakta pesanan, fakta produk, dan fakta pasangan pesanan-produk
-- masing-masing berumah sendiri.
CREATE TABLE pesanan (
nomor_pesanan integer PRIMARY KEY,
tanggal date NOT NULL,
nama_pelanggan text NOT NULL
);
CREATE TABLE produk (
kode_produk text PRIMARY KEY,
nama_produk text NOT NULL,
harga numeric(12, 2) NOT NULL
);
CREATE TABLE item_pesanan (
nomor_pesanan integer NOT NULL REFERENCES pesanan (nomor_pesanan),
kode_produk text NOT NULL REFERENCES produk (kode_produk),
kuantitas integer NOT NULL CHECK (kuantitas > 0),
PRIMARY KEY (nomor_pesanan, kode_produk)
);
-- Langkah 2: migrasi. DISTINCT mengeruk fakta yang tadinya tersimpan
-- berkali-kali menjadi satu baris di rumah barunya.
INSERT INTO pesanan (nomor_pesanan, tanggal, nama_pelanggan)
SELECT DISTINCT nomor_pesanan, tanggal, nama_pelanggan
FROM pesanan_detail
ORDER BY nomor_pesanan;
INSERT INTO produk (kode_produk, nama_produk, harga)
SELECT DISTINCT kode_produk, nama_produk, harga
FROM pesanan_detail
ORDER BY kode_produk;
-- Kuantitas satu-satunya kolom yang bergantung pada kunci utuh,
-- jadi kuantitas ikut pindah ke tabel pasangan.
INSERT INTO item_pesanan (nomor_pesanan, kode_produk, kuantitas)
SELECT nomor_pesanan, kode_produk, kuantitas
FROM pesanan_detail
ORDER BY nomor_pesanan, kode_produk;
-- Langkah 3: tabel lama pensiun setelah seluruh fakta berpindah.
DROP TABLE pesanan_detail;
-- Laporan disusun ulang lewat join: tampilan sama, redundansi hilang.
SELECT ps.nomor_pesanan, ps.tanggal, ps.nama_pelanggan,
pr.nama_produk, pr.harga, i.kuantitas
FROM pesanan ps
JOIN item_pesanan i ON i.nomor_pesanan = ps.nomor_pesanan
JOIN produk pr ON pr.kode_produk = i.kode_produk
ORDER BY ps.nomor_pesanan, pr.kode_produk;
-- Langkah 4: uji pulih. Koreksi tanggal pesanan kini menyentuh SATU baris.
UPDATE pesanan SET tanggal = DATE '2026-04-02'
WHERE nomor_pesanan = 501;
-- Perubahan nama produk pun menyentuh SATU baris, lalu terbaca konsisten
-- di seluruh pesanan yang memuat produk itu.
UPDATE produk SET nama_produk = 'Buku Tulis Gambar'
WHERE kode_produk = 'P01';
SELECT ps.nomor_pesanan, ps.tanggal, pr.nama_produk, i.kuantitas
FROM pesanan ps
JOIN item_pesanan i ON i.nomor_pesanan = ps.nomor_pesanan
JOIN produk pr ON pr.kode_produk = i.kode_produk
ORDER BY ps.nomor_pesanan, pr.kode_produk;Output yang Dihasilkan:
UPDATE 2
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 2
INSERT 0 3
DROP TABLE
nomor_pesanan | tanggal | nama_pelanggan | nama_produk | harga | kuantitas
---------------+------------+----------------+--------------+---------+-----------
501 | 2026-04-03 | Budi Santoso | Buku Tulis | 4500.00 | 10
501 | 2026-04-03 | Budi Santoso | Pulpen Hitam | 3000.00 | 5
502 | 2026-04-08 | Siti Rahayu | Buku Tulis | 4500.00 | 2
(3 rows)
UPDATE 1
UPDATE 1
nomor_pesanan | tanggal | nama_produk | kuantitas
---------------+------------+-------------------+-----------
501 | 2026-04-02 | Buku Tulis Gambar | 10
501 | 2026-04-02 | Pulpen Hitam | 5
502 | 2026-04-08 | Buku Tulis Gambar | 2
(3 rows)Tahapan file itu layak dibaca pelan-pelan:
- Rekonsiliasi sebelum migrasi — langkah nol mengembalikan keseragaman tanggal pesanan 501 ke
2026-04-03(nilai yang benar) di semua barisnya. Langkah ini wajib: bila data masih terbelah,SELECT DISTINCT nomor_pesanan, tanggalakan menghasilkan dua baris untuk 501 dan migrasi kepesananakan ditolak primary key-nya sendiri. Pelajaran praktisnya: normalisasi struktur selalu didahului pembersihan data. - Tiga tabel, tiga kelompok ketergantungan —
pesananmenampung yang bergantung padanomor_pesanan,produkmenampung yang bergantung padakode_produk, danitem_pesananhanya menyimpan kunci pasangan pluskuantitas. Composite primary key dan dua foreign key diitem_pesananadalah struktur junction yang dibahas penuh di chapter Junction Table . - Migrasi memakai
DISTINCT— dua insert pertama mengeruk fakta yang berulang menjadi satu baris;INSERT 0 2danINSERT 0 3adalah suara redundansi yang menyusut. - Uji pulih — dua
UPDATEterakhir masing-masing menyentuh satu baris: koreksi tanggal berhenti dipesanan, rename produk berhenti diproduk. Laporan join penutup membuktikan hasilnya terbaca konsisten di mana-mana: tanggal 502 tetap2026-04-08, dan namaBuku Tulis Gambartampil di kedua pesanan yang memuat P01.
2NF dan Kunci Surrogate #
Karena 2NF hanya berbicara tentang kunci composite, muncul godaan praktis: pakai saja id buatan sebagai primary key tunggal, dan 2NF “terpenuhi” secara otomatis. Secara definisi itu benar — kunci satu kolom tidak punya separuh kunci. Tetapi keamanan itu semu bila kolom-kolom natural masih menumpuk di tabel yang sama: tabel dengan id buatan plus kolom kode_produk, nama_produk, harga tetap menyimpan nama produk berkali-kali, dan anomali update yang sama hidup persis seperti sebelumnya.
Skema tiga tabel hasil dekomposisi kini berdiri rapi: 1NF dicek di chapter sebelumnya, 2NF dipenuhi karena setiap kolom non-kunci bergantung pada kunci secara utuh. Tetapi masih ada satu jenis ketergantungan yang lolos dari kedua pemeriksaan itu — ketergantungan yang melompat dua tingkat, dari kunci ke kolom non-kunci lalu ke kolom non-kunci lainnya. Ketergantungan transitif itulah lawan terakhir di chapter Third Normal Form .
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
anomali-parsial.sql
Unduh berkas-- Koreksi tanggal: pesanan 501 ternyata terjadi 2026-04-03, bukan 2026-04-02.
-- Tanggal adalah fakta milik PESANAN, tetapi rumahnya di tabel item --
-- sehingga petugas harus menyebut item tertentu saat mengubahnya.
UPDATE pesanan_detail SET tanggal = DATE '2026-04-03'
WHERE nomor_pesanan = 501 AND kode_produk = 'P02';
-- Pesanan yang sama kini punya dua tanggal: baris P01 masih 04-02,
-- baris P02 sudah 04-03.
SELECT nomor_pesanan, kode_produk, tanggal
FROM pesanan_detail
WHERE nomor_pesanan = 501
ORDER BY kode_produk;
-- Diterjemahkan menjadi query pemeriksa: satu pesanan, dua versi tanggal.
SELECT nomor_pesanan, count(DISTINCT tanggal) AS versi_tanggal
FROM pesanan_detail
GROUP BY nomor_pesanan
ORDER BY nomor_pesanan;
buat-detail.sql
Unduh berkas-- Tabel transaksi April Toko Maju Jaya: setiap sel satu nilai,
-- primary key composite dideklarasikan dengan sadar -- tabel ini
-- lolos 1NF, tetapi kolom non-kuncinya bersandar pada separuh kunci.
CREATE TABLE pesanan_detail (
nomor_pesanan integer NOT NULL,
kode_produk text NOT NULL,
tanggal date NOT NULL,
nama_pelanggan text NOT NULL,
nama_produk text NOT NULL,
harga numeric(12, 2) NOT NULL,
kuantitas integer NOT NULL,
PRIMARY KEY (nomor_pesanan, kode_produk)
);
INSERT INTO pesanan_detail (nomor_pesanan, kode_produk, tanggal,
nama_pelanggan, nama_produk, harga, kuantitas) VALUES
(501, 'P01', DATE '2026-04-02', 'Budi Santoso', 'Buku Tulis', 4500.00, 10),
(501, 'P02', DATE '2026-04-02', 'Budi Santoso', 'Pulpen Hitam', 3000.00, 5),
(502, 'P01', DATE '2026-04-08', 'Siti Rahayu', 'Buku Tulis', 4500.00, 2);
SELECT nomor_pesanan, kode_produk, tanggal, nama_pelanggan,
nama_produk, harga, kuantitas
FROM pesanan_detail
ORDER BY nomor_pesanan, kode_produk;
-- Diagnosis sisi produk: fakta "P01 bernama Buku Tulis, harga 4500.00"
-- tersimpan di dua baris, karena hanya bergantung pada kode_produk.
SELECT kode_produk, nama_produk, harga, count(*) AS baris_penyimpan
FROM pesanan_detail
GROUP BY kode_produk, nama_produk, harga
ORDER BY kode_produk;
-- Diagnosis sisi pesanan: fakta "501 terjadi 2026-04-02 atas nama
-- Budi Santoso" juga tersimpan dua kali, karena hanya bergantung
-- pada nomor_pesanan.
SELECT nomor_pesanan, tanggal, nama_pelanggan, count(*) AS baris_penyimpan
FROM pesanan_detail
GROUP BY nomor_pesanan, tanggal, nama_pelanggan
ORDER BY nomor_pesanan;
dekomposisi-2nf.sql
Unduh berkas-- Langkah 0: rekonsiliasi data sebelum migrasi.
-- Tanggal yang benar untuk seluruh pesanan 501 adalah 2026-04-03
-- (koreksi di file sebelumnya memang benar -- yang salah adalah bentuknya:
-- hanya satu baris yang tersentuh).
UPDATE pesanan_detail SET tanggal = DATE '2026-04-03'
WHERE nomor_pesanan = 501;
-- Langkah 1: tiga tabel dengan pembagian kerja yang jelas.
-- Fakta pesanan, fakta produk, dan fakta pasangan pesanan-produk
-- masing-masing berumah sendiri.
CREATE TABLE pesanan (
nomor_pesanan integer PRIMARY KEY,
tanggal date NOT NULL,
nama_pelanggan text NOT NULL
);
CREATE TABLE produk (
kode_produk text PRIMARY KEY,
nama_produk text NOT NULL,
harga numeric(12, 2) NOT NULL
);
CREATE TABLE item_pesanan (
nomor_pesanan integer NOT NULL REFERENCES pesanan (nomor_pesanan),
kode_produk text NOT NULL REFERENCES produk (kode_produk),
kuantitas integer NOT NULL CHECK (kuantitas > 0),
PRIMARY KEY (nomor_pesanan, kode_produk)
);
-- Langkah 2: migrasi. DISTINCT mengeruk fakta yang tadinya tersimpan
-- berkali-kali menjadi satu baris di rumah barunya.
INSERT INTO pesanan (nomor_pesanan, tanggal, nama_pelanggan)
SELECT DISTINCT nomor_pesanan, tanggal, nama_pelanggan
FROM pesanan_detail
ORDER BY nomor_pesanan;
INSERT INTO produk (kode_produk, nama_produk, harga)
SELECT DISTINCT kode_produk, nama_produk, harga
FROM pesanan_detail
ORDER BY kode_produk;
-- Kuantitas satu-satunya kolom yang bergantung pada kunci utuh,
-- jadi kuantitas ikut pindah ke tabel pasangan.
INSERT INTO item_pesanan (nomor_pesanan, kode_produk, kuantitas)
SELECT nomor_pesanan, kode_produk, kuantitas
FROM pesanan_detail
ORDER BY nomor_pesanan, kode_produk;
-- Langkah 3: tabel lama pensiun setelah seluruh fakta berpindah.
DROP TABLE pesanan_detail;
-- Laporan disusun ulang lewat join: tampilan sama, redundansi hilang.
SELECT ps.nomor_pesanan, ps.tanggal, ps.nama_pelanggan,
pr.nama_produk, pr.harga, i.kuantitas
FROM pesanan ps
JOIN item_pesanan i ON i.nomor_pesanan = ps.nomor_pesanan
JOIN produk pr ON pr.kode_produk = i.kode_produk
ORDER BY ps.nomor_pesanan, pr.kode_produk;
-- Langkah 4: uji pulih. Koreksi tanggal pesanan kini menyentuh SATU baris.
UPDATE pesanan SET tanggal = DATE '2026-04-02'
WHERE nomor_pesanan = 501;
-- Perubahan nama produk pun menyentuh SATU baris, lalu terbaca konsisten
-- di seluruh pesanan yang memuat produk itu.
UPDATE produk SET nama_produk = 'Buku Tulis Gambar'
WHERE kode_produk = 'P01';
SELECT ps.nomor_pesanan, ps.tanggal, pr.nama_produk, i.kuantitas
FROM pesanan ps
JOIN item_pesanan i ON i.nomor_pesanan = ps.nomor_pesanan
JOIN produk pr ON pr.kode_produk = i.kode_produk
ORDER BY ps.nomor_pesanan, pr.kode_produk;