Setelah tujuh chapter, kita memegang dua cara berpikir: join menggabungkan baris dari beberapa tabel menjadi satu hasil, sedangkan subquery menyelipkan pertanyaan kecil di dalam query besar. Kabar baiknya, banyak soal nyata bisa dijawab keduanya. Kabar yang perlu dikelola: hasil yang sama tidak menjamin biaya yang sama, kejelasan yang sama, atau semantik yang sama. Pada chapter penutup ini kita menjalankan dua soal klasik dengan kedua kubu, membandingkan hasilnya baris demi baris, lalu menutup dengan tabel keputusan dan daftar situasi yang benar-benar menuntut salah satu pihak.

Menyiapkan Empat Tabel Latihan #

Empat tabel toko daring yang sama dipakai untuk terakhir kalinya, lengkap dengan pelanggan tanpa pesanan (Andi, Dewi), produk tanpa transaksi (Teh Hijau, Susu Kental), dan pesanan tunai tanpa pelanggan terdaftar:

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

Output yang Dihasilkan:

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

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  kategori VARCHAR(30) NOT NULL,
  harga DECIMAL(10,2) NOT NULL
);

CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL
);

CREATE TABLE pesanan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tanggal DATE NOT NULL,
  pelanggan INT NULL
);

CREATE TABLE detail_pesanan (
  pesanan_id INT NOT NULL,
  produk_id INT NOT NULL,
  jumlah INT NOT NULL
);

INSERT INTO produk (nama, kategori, harga) VALUES
  ('Kopi Gayo', 'kopi', 55000.00),
  ('Kopi Toraja', 'kopi', 62000.00),
  ('Teh Melati', 'teh', 25000.00),
  ('Teh Hijau', 'teh', 30000.00),
  ('Gula Aren', 'pemanis', 15000.00),
  ('Susu Kental', 'pemanis', 18000.00);

INSERT INTO pelanggan (nama) VALUES
  ('Budi'), ('Siti'), ('Rina'), ('Andi'), ('Dewi');

INSERT INTO pesanan (tanggal, pelanggan) VALUES
  ('2026-01-05', 1),
  ('2026-01-06', 2),
  ('2026-01-08', 1),
  ('2026-01-10', 3),
  ('2026-01-12', 2),
  ('2026-01-15', NULL);

INSERT INTO detail_pesanan (pesanan_id, produk_id, jumlah) VALUES
  (1, 1, 2),
  (1, 5, 1),
  (2, 3, 3),
  (3, 3, 1),
  (4, 2, 2),
  (4, 3, 2),
  (5, 1, 1),
  (5, 5, 4),
  (6, 2, 1);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

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

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

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

Total belanja per pelanggan bila dihitung manual: Siti 190000, Rina 174000, Budi 150000. Pelanggan dengan pesanan: Budi, Rina, Siti.

Soal Keberadaan: JOIN versus EXISTS #

Soal pertama: “pelanggan mana yang punya setidaknya satu pesanan?” Kubu join menggabungkan pelanggan dengan pesanan:

sql
SELECT pelanggan.nama FROM pelanggan JOIN pesanan ON pesanan.pelanggan = pelanggan.id ORDER BY pelanggan.nama;

Output yang Dihasilkan:

text
+------+
| nama |
+------+
| Budi |
| Budi |
| Rina |
| Siti |
| Siti |
+------+
5 rows in set (0.00 sec)

Hasilnya lima baris, padahal pelanggan dengan pesanan hanya tiga orang. Join menduplikasi baris pelanggan sesuai jumlah pesanannya — Budi tampil dua kali, Siti dua kali — karena join memang menggabungkan setiap pasangan baris yang cocok. Untuk mendapatkan daftar yang benar, DISTINCT harus ditambahkan:

sql
SELECT DISTINCT pelanggan.nama FROM pelanggan JOIN pesanan ON pesanan.pelanggan = pelanggan.id ORDER BY pelanggan.nama;

Output yang Dihasilkan:

text
+------+
| nama |
+------+
| Budi |
| Rina |
| Siti |
+------+
3 rows in set (0.00 sec)

Sekarang kubu subquery menjawab soal yang sama dengan EXISTS:

sql
SELECT nama FROM pelanggan WHERE EXISTS (SELECT 1 FROM pesanan WHERE pesanan.pelanggan = pelanggan.id) ORDER BY nama;

Output yang Dihasilkan:

text
+------+
| nama |
+------+
| Budi |
| Rina |
| Siti |
+------+
3 rows in set (0.00 sec)

Ketiga pelanggan yang sama muncul, tanpa DISTINCT. Perbandingannya mencerminkan karakter masing-masing: join bekerja dengan cara menggabungkan baris sehingga hasil mentahnya membawa duplikat (dan kita sudah membahas jebakan ini di chapter menghindari duplikasi hasil join), sedangkan EXISTS bekerja dengan cara bertanya per pelanggan sehingga setiap pelanggan paling banyak muncul sekali — tidak ada yang perlu dibersihkan. Untuk soal keberadaan murni, EXISTS mengungguli join dalam hal kejelasan; keunggulan biayanya bergantung pada data dan index, karena DISTINCT pun bisa dijalankan murah di MySQL 8.

Laporan Nilai: Join Langsung versus Derived Table #

Soal kedua: “berapa total belanja setiap pelanggan?” Versi join menggabungkan empat tabel lalu mengagregasi:

sql
SELECT pelanggan.nama, SUM(detail_pesanan.jumlah * produk.harga) AS total_belanja
FROM pelanggan
JOIN pesanan ON pesanan.pelanggan = pelanggan.id
JOIN detail_pesanan ON detail_pesanan.pesanan_id = pesanan.id
JOIN produk ON produk.id = detail_pesanan.produk_id
GROUP BY pelanggan.nama
ORDER BY total_belanja DESC;

Output yang Dihasilkan:

text
+------+---------------+
| nama | total_belanja |
+------+---------------+
| Siti |     190000.00 |
| Rina |     174000.00 |
| Budi |     150000.00 |
+------+---------------+
3 rows in set (0.00 sec)

Versi subquery memakai pola derived table dari chapter Subquery dalam FROM — agregat dulu di dalam subquery, join belakangan untuk nama:

sql
SELECT pelanggan.nama, rekap.total_belanja
FROM pelanggan
JOIN (
  SELECT pesanan.pelanggan, SUM(detail_pesanan.jumlah * produk.harga) AS total_belanja
  FROM pesanan
  JOIN detail_pesanan ON detail_pesanan.pesanan_id = pesanan.id
  JOIN produk ON produk.id = detail_pesanan.produk_id
  GROUP BY pesanan.pelanggan
) AS rekap ON rekap.pelanggan = pelanggan.id
ORDER BY rekap.total_belanja DESC;

Output yang Dihasilkan:

text
+------+---------------+
| nama | total_belanja |
+------+---------------+
| Siti |     190000.00 |
| Rina |     174000.00 |
| Budi |     150000.00 |
+------+---------------+
3 rows in set (0.00 sec)

Kedua versi menghasilkan tiga baris dengan angka yang identik: Siti 190000, Rina 174000, Budi 150000. Bedanya terletak pada struktur, bukan hasil. Versi join mengagregasi setelah menggabungkan semua tabel termasuk nama pelanggan, sehingga klausa GROUP BY bekerja di atas hasil join besar. Versi derived table mengagregasi sebelum menyentuh tabel pelanggan — subquery merangkum transaksi menjadi tabel kecil satu-baris-per-pelanggan, dan query luar tinggal menempelkan nama. Untuk pembandingan kecil seperti ini keduanya sama-sama cepat; pada join yang jauh lebih lebar (banyak kolom dari banyak tabel), memisahkan agregasi ke dalam derived table sering membuat query lebih mudah dioptimasi dan lebih mudah dibaca.

Tabel Keputusan #

Ringkasan praktis hasil perbandingan sepanjang section ini:

AspekJOINSubquery
Butuh kolom dari kedua tabel di satu barisMenang mutlak — inilah tujuan lahirnya joinTidak bisa: subquery menghasilkan nilai atau daftar, bukan kolom gabungan
Pertanyaan keberadaan (“punya / belum punya”)Bisa, tetapi rawan duplikat baris; butuh DISTINCT atau LEFT JOIN ... IS NULLEXISTS / NOT EXISTS jelas dan tahan NULL
Nilai agregat sebagai pembanding (harga > AVG ...)Butuh derived table sebagai perantaraSubquery scalar langsung menempel di kondisi
Semantik ALL / ANYTidak punya padanan langsungSatu-satunya jalan: > ALL, > ANY
KeterbacaanBagus untuk relasi antartabel yang alamiBagus untuk pertanyaan bertingkat yang alami
Performa di tabel besarSering lebih baik bila agregasi dan join dipisahCorrelated subquery berisiko N kali evaluasi

Dua baris terakhir sengaja tidak menyebut pemenang: keterbacaan bergantung pada siapa membacanya, dan performa bergantung pada data, index, dan kemampuan optimizer menulis ulang query — dua query yang hasilnya sama bisa berjalan dengan rencana eksekusi yang berbeda. Kebiasaan yang sehat: tulis versi yang paling jelas dulu, lalu ukur bila query itu penting dan berjalan di data besar.

Kapan Subquery Lebih Tepat #

Tiga situasi menempatkan subquery di posisi paling wajar. Pertama, semantik ALL dan ANY: kalimat bisnis “produk yang lebih mahal dari semua produk teh” diterjemahkan nyaris kata per kata menjadi harga > ALL (SELECT harga FROM produk WHERE kategori = 'teh') — join tidak punya padanan langsung untuk “lebih dari semua”. Kedua, pertanyaan “yang belum punya”: NOT EXISTS membaca persis seperti niatnya dan kebal terhadap NULL, seperti kita buktikan di chapter EXISTS dan NOT EXISTS. Ketiga, nilai agregat sebagai ambang: harga > (SELECT AVG(harga) ...) menempelkan hitungan langsung di kondisi tanpa perantara. Sebenarnya hampir semuanya tetap punya padanan join — pertanyaan rata-rata per kategori dari chapter Correlated Subquery, misalnya, bisa juga dijawab dengan join ke derived table berisi rata-rata per kategori — tetapi padanan itu hampir selalu lebih panjang dan lebih banyak langkah.

Kapan Join Lebih Unggul #

Saat baris hasil perlu menampilkan kolom dari beberapa tabel sekaligus, join menang tanpa tanding. Laporan “tiga transaksi bernilai terbesar beserta nama pembeli dan nama produk” membutuhkan kolom dari tiga tabel pada baris yang sama:

sql
SELECT pelanggan.nama AS nama_pelanggan, produk.nama AS nama_produk, detail_pesanan.jumlah
FROM pesanan
JOIN pelanggan ON pelanggan.id = pesanan.pelanggan
JOIN detail_pesanan ON detail_pesanan.pesanan_id = pesanan.id
JOIN produk ON produk.id = detail_pesanan.produk_id
ORDER BY detail_pesanan.jumlah * produk.harga DESC, pesanan.id
LIMIT 3;

Output yang Dihasilkan:

text
+----------------+-------------+--------+
| nama_pelanggan | nama_produk | jumlah |
+----------------+-------------+--------+
| Rina           | Kopi Toraja |      2 |
| Budi           | Kopi Gayo   |      2 |
| Siti           | Teh Melati  |      3 |
+----------------+-------------+--------+
3 rows in set (0.00 sec)

Tiga baris hasil, masing-masing memuat nama pelanggan, nama produk, dan jumlah dalam satu baris gabungan. Mencapai bentuk yang sama dengan subquery menuntut tiga subquery berkorelasi di daftar SELECT — sekali per kolom yang dibutuhkan — jauh lebih ribet daripada satu rangkaian join. Inilah aturan praktis paling kuat dari seluruh perbandingan: kalau jawaban perlu menampilkan data dari tabel lain, pakai join; kalau jawaban hanya perlu tahu sesuatu tentang tabel lain (ada tidaknya, rata-ratanya, daftar id-nya), subquery layak jadi kandidat pertama.

Membersihkan Database Latihan #

Rangkaian latihan selesai dan database latihan kita jatuhkan kembali:

sql
DROP DATABASE belajar_sub;

Output yang Dihasilkan:

text
Query OK, 4 rows affected (0.00 sec)

Referensi #

Kita sekarang bisa menjawab pertanyaan bertingkat dengan subquery di WHERE, FROM, dan SELECT, menjawab soal keberadaan dengan EXISTS, dan memilih secara sadar antara subquery dan join. Query bertingkat yang mulai panjang dan bertele-tele punya obat berikutnya: WITH — Common Table Expressions yang memberi nama pada subquery supaya bisa dipanggil berkali-kali. Section set operations dan CTE membahasnya bersama UNION untuk menggabungkan hasil query.