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 ketergantunganContoh pada tabel kitaVonis
bergantung pada kunci utuhkuantitas bergantung pada pasangan pesanan-produksah
bergantung pada separuh kuncinama_produk bergantung pada kode_produk sajaparsial — pelanggaran 2NF
bergantung pada separuh kuncitanggal bergantung pada nomor_pesanan sajaparsial — 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:

sql buat-detail.sql
-- 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:

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

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

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

  1. 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, tanggal akan menghasilkan dua baris untuk 501 dan migrasi ke pesanan akan ditolak primary key-nya sendiri. Pelajaran praktisnya: normalisasi struktur selalu didahului pembersihan data.
  2. Tiga tabel, tiga kelompok ketergantungan — pesanan menampung yang bergantung pada nomor_pesanan, produk menampung yang bergantung pada kode_produk, dan item_pesanan hanya menyimpan kunci pasangan plus kuantitas. Composite primary key dan dua foreign key di item_pesanan adalah struktur junction yang dibahas penuh di chapter Junction Table .
  3. Migrasi memakai DISTINCT — dua insert pertama mengeruk fakta yang berulang menjadi satu baris; INSERT 0 2 dan INSERT 0 3 adalah suara redundansi yang menyusut.
  4. Uji pulih — dua UPDATE terakhir masing-masing menyentuh satu baris: koreksi tanggal berhenti di pesanan, rename produk berhenti di produk. Laporan join penutup membuktikan hasilnya terbaca konsisten di mana-mana: tanggal 502 tetap 2026-04-08, dan nama Buku Tulis Gambar tampil 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
sql
-- 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
sql
-- 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
sql
-- 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;