Toko Maju Jaya bisa saja menyimpan seluruh catatan penjualannya dalam satu tabel raksasa: nama pelanggan, kota, email, tanggal pesanan, dan total belanja ditulis ulang di setiap baris. Desain seperti itu terasa praktis pada minggu pertama, lalu berubah menjadi beban ketika seorang pelanggan pindah kota dan datanya harus diubah di banyak baris sekaligus. Database relational menyelesaikan masalah ini dengan arah sebaliknya: data dipecah ke tabel-tabel kecil yang masing-masing mengurus satu fakta, lalu dihubungkan kembali lewat relasi antarkolom saat query dijalankan. Chapter ini menjelaskan mengapa pemecahan itu perlu, bagaimana pasangan primary key dan foreign key menjadi tulang punggung relasi, dan memperkenalkan query JOIN pertama kita sebelum section ini membongkar setiap jenis join satu per satu.
Kenapa Data Dipecah ke Banyak Tabel #
Menggabungkan semua kolom ke satu tabel menciptakan dua masalah klasik. Pertama, redundansi: data pelanggan yang sama ditulis berulang di setiap pesanannya, memakan tempat dan mempersulit pembacaan. Kedua, dan lebih berbahaya, anomali pembaruan: ketika satu fakta tersimpan di banyak baris, satu fakta itu bisa saling bertentangan begitu sebagian baris diperbarui dan sebagian lagi terlupakan. Mari kita lihat anomali itu terjadi secara nyata:
-- Desain buruk: seluruh data pelanggan diduplikasi di setiap pesanan
CREATE TABLE pesanan_gabung (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL,
tanggal date NOT NULL,
total numeric(12, 2) NOT NULL
);
INSERT INTO pesanan_gabung (id, nama, kota, email, tanggal, total) VALUES
(101, 'Budi Santoso', 'Bandung', '[email protected]', DATE '2026-01-05', 150000.00),
(102, 'Budi Santoso', 'Bandung', '[email protected]', DATE '2026-01-18', 75000.00),
(103, 'Siti Rahayu', 'Jakarta', '[email protected]', DATE '2026-01-09', 220000.00),
(104, 'Dewi Lestari', 'Medan', '[email protected]', DATE '2026-02-02', 50000.00);
-- Budi pindah kota, tetapi hanya satu baris yang sempat di-update
UPDATE pesanan_gabung SET kota = 'Jakarta' WHERE id = 101;
SELECT id, nama, kota, total FROM pesanan_gabung ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 4
UPDATE 1
id | nama | kota | total
-----+--------------+---------+-----------
101 | Budi Santoso | Jakarta | 150000.00
102 | Budi Santoso | Bandung | 75000.00
103 | Siti Rahayu | Jakarta | 220000.00
104 | Dewi Lestari | Medan | 50000.00
(4 rows)Tabel pesanan_gabung tampak rapi sampai baris terakhir dieksekusi. Mari kita bedah kejadiannya:
INSERT INTO pesanan_gabung ... VALUES: empat baris pesanan masuk, dan data Budi Santoso (nama, kota, email) tercatat dua kali — sekali untuk pesanan 101 dan sekali untuk pesanan 102.UPDATE pesanan_gabung SET kota = 'Jakarta' WHERE id = 101: hanya baris pesanan 101 yang berubah. Baris 102 yang memuat fakta “Budi tinggal di Bandung” tidak tersentuh.SELECT ... ORDER BY id: bukti kerusakan. Budi Santoso kini tampil dengan dua kota berbeda —Jakartadi baris 101 danBandungdi baris 102 — padahal kota adalah fakta tunggal tentang satu orang.
Desain ini juga menderita anomali saat menambah data: pelanggan baru yang belum pernah memesan apa pun tidak punya tempat yang wajar di tabel pesanan. Penyembuhan yang benar bukan menambah kolom, melainkan memecah data: fakta pelanggan disimpan satu kali di tabel pelanggan, fakta transaksi disimpan di tabel pesanan, dan kedua tabel dihubungkan lewat kolom rujukan. Aturan pemecahan ini disebut normalisasi; teori lengkapnya dibahas di section Relational Database Design
, sementara section JOIN ini fokus pada sisi pemakainya — menggabungkan kembali data yang sudah dipecah.
Primary Key dan Foreign Key sebagai Tulang Punggung Relasi #
Relasi antartabel di PostgreSQL bertumpu pada pasangan kolom: primary key di tabel induk dan foreign key di tabel anak. pelanggan.id adalah primary key yang menjamin setiap pelanggan punya identitas tunggal; pesanan.pelanggan_id adalah foreign key yang merujuk kolom tersebut sehingga server menolak pesanan yang menunjuk pelanggan hantu. Perilaku constraint ini — termasuk aksi ON DELETE — sudah kita bahas tuntas di chapter Foreign Key
; di section ini kita memakainya sebagai jangkar penggabungan.
Skema contoh kita memuat dua jenis relasi yang akan dipakai berulang kali di seluruh section:
| Pasangan tabel | Kolom penghubung | Dijaga foreign key? |
|---|---|---|
pesanan → pelanggan | pesanan.pelanggan_id = pelanggan.id | ya — server menolak rujukan hantu |
daftar_email → pelanggan | daftar_email.email = pelanggan.email | tidak — hanya kesamaan nilai |
Diagram berikut memetakan hubungan ketiga tabel tersebut:
Panah penuh dari pesanan ke pelanggan adalah relasi yang dijaga foreign key: server memastikan setiap nilai pelanggan_id yang tidak kosong benar-benar ada di pelanggan.id. Panah putus-putus dari daftar_email adalah relasi lembut: tabel ini berisi kontak hasil undangan newsletter, dan kecocokannya dengan pelanggan.email murni kesamaan nilai — tidak ada constraint yang menjaganya, sehingga baris di kedua sisi boleh tidak punya pasangan. Dua jenis relasi ini sengaja dipilih agar section ini bisa mendemonstrasikan join pada data bersih (FK) sekaligus data yang tidak sempurna (daftar lembut).
Skema Contoh untuk Seluruh Section #
Ketiga tabel berikut akan dibuat ulang di setiap chapter dengan isi yang persis sama, sehingga setiap topik join bisa dibaca mandiri. Perhatikan dua baris yang sengaja dibuat “yatim”: pelanggan tanpa pesanan, dan pesanan tanpa pelanggan:
-- Desain relational: satu fakta hidup di satu tempat saja
DROP TABLE IF EXISTS pesanan_gabung;
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE pesanan (
id integer PRIMARY KEY,
pelanggan_id integer REFERENCES pelanggan (id),
tanggal date NOT NULL,
total numeric(12, 2) NOT NULL
);
CREATE TABLE daftar_email (
email text PRIMARY KEY,
nama text NOT NULL,
sumber text NOT NULL
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]'),
(3, 'Andi Wijaya', 'Surabaya', '[email protected]'),
(4, 'Dewi Lestari', 'Medan', '[email protected]');
INSERT INTO pesanan (id, pelanggan_id, tanggal, total) VALUES
(101, 1, DATE '2026-01-05', 150000.00),
(102, 1, DATE '2026-01-18', 75000.00),
(103, 2, DATE '2026-01-09', 220000.00),
(104, 4, DATE '2026-02-02', 50000.00),
(105, NULL, DATE '2026-02-10', 30000.00);
INSERT INTO daftar_email (email, nama, sumber) VALUES
('[email protected]', 'Budi Santoso', 'newsletter'),
('[email protected]', 'Siti Rahayu', 'pameran'),
('[email protected]', 'Ratna Kusuma', 'newsletter'),
('[email protected]', 'Yusuf Halim', 'webinar');
SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;
SELECT * FROM daftar_email ORDER BY email;Output yang Dihasilkan:
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
INSERT 0 4
id | nama | kota | email
----+--------------+----------+------------------
1 | Budi Santoso | Bandung | [email protected]
2 | Siti Rahayu | Jakarta | [email protected]
3 | Andi Wijaya | Surabaya | [email protected]
4 | Dewi Lestari | Medan | [email protected]
(4 rows)
id | pelanggan_id | tanggal | total
-----+--------------+------------+-----------
101 | 1 | 2026-01-05 | 150000.00
102 | 1 | 2026-01-18 | 75000.00
103 | 2 | 2026-01-09 | 220000.00
104 | 4 | 2026-02-02 | 50000.00
105 | | 2026-02-10 | 30000.00
(5 rows)
email | nama | sumber
-------------------+--------------+------------
[email protected] | Budi Santoso | newsletter
[email protected] | Ratna Kusuma | newsletter
[email protected] | Siti Rahayu | pameran
[email protected] | Yusuf Halim | webinar
(4 rows)Tiga keputusan desain di dalam skema ini menentukan seluruh contoh section JOIN:
pelanggan_id integer REFERENCES pelanggan (id)tanpaNOT NULL— kolom rujukan boleh kosong. Pesanan 105 adalah transaksi walk-in kasir: pelanggan tunai tanpa akun, sehinggapelanggan_id-nya NULL. Baris ini akan menjadi bintang demo di chapter RIGHT JOIN dan FULL OUTER JOIN.Andi Wijayatidak punya satu pun pesanan — pelanggan terdaftar yang belum pernah berbelanja. Baris ini menjadi bintang demo LEFT JOIN.[email protected]dan[email protected]hanya ada didaftar_email— kontak undangan yang belum menjadi pelanggan. Kedua baris ini menjadi bahan demo FULL OUTER JOIN untuk merekonsiliasi dua daftar.
Join Pertama: Menyatukan Kembali Data yang Terpisah #
Ketika data pelanggan dan data pesanan hidup di tabel terpisah, laporan “siapa memesan apa” harus menyatukan keduanya saat query berjalan. Klausa JOIN ... ON melakukan pekerjaan itu: untuk setiap baris tabel kiri, PostgreSQL mencari baris tabel kanan yang memenuhi kondisi ON, lalu menggabungkan kolom keduanya menjadi satu baris hasil. Dua query berikut memakai relasi FK dan relasi lembut dari skema kita:
-- Relasi PK-FK: gabungkan pesanan dengan pelanggan induknya
SELECT p.nama, ps.id AS pesanan, ps.tanggal, ps.total
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY ps.id;
-- Relasi lembut tanpa FK: cocokkan daftar_email dengan pelanggan lewat email
SELECT d.nama AS nama_daftar, p.nama AS nama_pelanggan, p.kota
FROM daftar_email d
JOIN pelanggan p ON p.email = d.email
ORDER BY p.id;Output yang Dihasilkan:
nama | pesanan | tanggal | total
--------------+---------+------------+-----------
Budi Santoso | 101 | 2026-01-05 | 150000.00
Budi Santoso | 102 | 2026-01-18 | 75000.00
Siti Rahayu | 103 | 2026-01-09 | 220000.00
Dewi Lestari | 104 | 2026-02-02 | 50000.00
(4 rows)
nama_daftar | nama_pelanggan | kota
--------------+----------------+---------
Budi Santoso | Budi Santoso | Bandung
Siti Rahayu | Siti Rahayu | Jakarta
(2 rows)Mari kita bedah query pertama baris per baris:
FROM pelanggan p— tabelpelanggandiberi aliaspagar referensi kolomnya ringkas. Alias tabel adalah kebiasaan wajib begitu dua tabel bergabung, karena kolom bernama sama (misalnyaid) harus dibedakan asalnya.JOIN pesanan ps ON ps.pelanggan_id = p.id— inti relasinya. Untuk setiap pasangan baris(pelanggan, pesanan), pasangan lolos bila nilaips.pelanggan_idsama denganp.id; itulah pasangan PK-FK yang kita bahas di atas.SELECT p.nama, ps.id AS pesanan, ...— kolom dipilih dengan kualifikasitabel.kolom. Kolomps.iddiberi labelpesananagar pembaca laporan tidak mengiranya sebagai id pelanggan.ORDER BY ps.id— hasil diurutkan menurut id pesanan agar output stabil dan mudah dibaca.
Hasil pertama memuat empat baris, padahal pesanan berisi lima: pesanan 105 (walk-in) tidak punya pasangan karena NULL tidak pernah sama dengan nilai apa pun. Hasil kedua hanya memuat dua baris dari empat: [email protected] dan [email protected] tidak menemukan pelanggan yang cocok. Baris tanpa pasangan memang dibuang oleh bentuk JOIN ini — perilaku persis itulah yang diberi nama formal di chapter INNER JOIN
, sementara cara mempertahankan baris tanpa pasangan dibahas berturut-turut di chapter LEFT JOIN sampai FULL OUTER JOIN.
Gaya Lama: Comma Join #
SQL lama menulis penggabungan tanpa kata JOIN — kedua tabel dipisah koma di klausa FROM, lalu kondisi penggabungannya diselipkan di WHERE:
-- Gaya lama: comma join, kondisi penggabungan bersembunyi di WHERE
SELECT p.nama, ps.id AS pesanan, ps.tanggal, ps.total
FROM pelanggan p, pesanan ps
WHERE ps.pelanggan_id = p.id
ORDER BY ps.id;Output yang Dihasilkan:
nama | pesanan | tanggal | total
--------------+---------+------------+-----------
Budi Santoso | 101 | 2026-01-05 | 150000.00
Budi Santoso | 102 | 2026-01-18 | 75000.00
Siti Rahayu | 103 | 2026-01-09 | 220000.00
Dewi Lestari | 104 | 2026-02-02 | 50000.00
(4 rows)Hasilnya identik dengan query pertama join-pertama.sql — empat baris yang sama. PostgreSQL menerima kedua gaya, tetapi gaya eksplisit JOIN ... ON lebih aman dibaca ulang: kondisi relasi menempel langsung pada pasangan tabelnya, sedangkan WHERE tetap bersih sebagai tempat filter bisnis. Pada comma join, lupa menulis satu kondisi di WHERE tidak memicu error — hasilnya meledak menjadi semua kombinasi baris, bencana kartesian yang kita bedah di chapter CROSS JOIN
.
Dengan fondasi ini, kita siap membedah join yang paling sering dipakai: bentuk JOIN yang baru saja kita jalankan sebenarnya punya nama lengkap INNER JOIN, dan aturan tepatnya tentang baris mana yang bertahan atau terbuang layak dipelajari sampai tuntas. Chapter berikutnya membongkar INNER JOIN sampai ke detailnya — termasuk sintaks USING yang lebih ringkas.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Desain relational: satu fakta hidup di satu tempat saja
DROP TABLE IF EXISTS pesanan_gabung;
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE pesanan (
id integer PRIMARY KEY,
pelanggan_id integer REFERENCES pelanggan (id),
tanggal date NOT NULL,
total numeric(12, 2) NOT NULL
);
CREATE TABLE daftar_email (
email text PRIMARY KEY,
nama text NOT NULL,
sumber text NOT NULL
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]'),
(3, 'Andi Wijaya', 'Surabaya', '[email protected]'),
(4, 'Dewi Lestari', 'Medan', '[email protected]');
INSERT INTO pesanan (id, pelanggan_id, tanggal, total) VALUES
(101, 1, DATE '2026-01-05', 150000.00),
(102, 1, DATE '2026-01-18', 75000.00),
(103, 2, DATE '2026-01-09', 220000.00),
(104, 4, DATE '2026-02-02', 50000.00),
(105, NULL, DATE '2026-02-10', 30000.00);
INSERT INTO daftar_email (email, nama, sumber) VALUES
('[email protected]', 'Budi Santoso', 'newsletter'),
('[email protected]', 'Siti Rahayu', 'pameran'),
('[email protected]', 'Ratna Kusuma', 'newsletter'),
('[email protected]', 'Yusuf Halim', 'webinar');
SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;
SELECT * FROM daftar_email ORDER BY email;
join-lawas.sql
Unduh berkas-- Gaya lama: comma join, kondisi penggabungan bersembunyi di WHERE
SELECT p.nama, ps.id AS pesanan, ps.tanggal, ps.total
FROM pelanggan p, pesanan ps
WHERE ps.pelanggan_id = p.id
ORDER BY ps.id;
join-pertama.sql
Unduh berkas-- Relasi PK-FK: gabungkan pesanan dengan pelanggan induknya
SELECT p.nama, ps.id AS pesanan, ps.tanggal, ps.total
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY ps.id;
-- Relasi lembut tanpa FK: cocokkan daftar_email dengan pelanggan lewat email
SELECT d.nama AS nama_daftar, p.nama AS nama_pelanggan, p.kota
FROM daftar_email d
JOIN pelanggan p ON p.email = d.email
ORDER BY p.id;
tabel-melebur.sql
Unduh berkas-- Desain buruk: seluruh data pelanggan diduplikasi di setiap pesanan
CREATE TABLE pesanan_gabung (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL,
tanggal date NOT NULL,
total numeric(12, 2) NOT NULL
);
INSERT INTO pesanan_gabung (id, nama, kota, email, tanggal, total) VALUES
(101, 'Budi Santoso', 'Bandung', '[email protected]', DATE '2026-01-05', 150000.00),
(102, 'Budi Santoso', 'Bandung', '[email protected]', DATE '2026-01-18', 75000.00),
(103, 'Siti Rahayu', 'Jakarta', '[email protected]', DATE '2026-01-09', 220000.00),
(104, 'Dewi Lestari', 'Medan', '[email protected]', DATE '2026-02-02', 50000.00);
-- Budi pindah kota, tetapi hanya satu baris yang sempat di-update
UPDATE pesanan_gabung SET kota = 'Jakarta' WHERE id = 101;
SELECT id, nama, kota, total FROM pesanan_gabung ORDER BY id;