Angka laporan yang salah paling sering bukan lahir dari query yang gagal — melainkan dari query yang berhasil, kelihatan wajar, dan menghitung baris induk berkali-kali. Skenarionya selalu sama: satu pelanggan memiliki banyak pesanan, query menggabungkan keduanya, dan pelanggan itu muncul sebanyak pesanannya. count(*) pun melonjak, sum() pada kolom pelanggan ikut membengkak, dan laporan “total poin seluruh pelanggan” diam-diam menjual angka yang tidak mungkin diverifikasi. Query-nya tidak error karena memang tidak salah secara sintaks — inilah bug yang paling sulit ditangkap review kode. Chapter penutup section ini membedah mekanisme duplikasi hasil join, menguji obat-obat palsunya, lalu memasang solusi standarnya: agregasi di sisi anak SEBELUM digabungkan, ditambah dua pola pendukung — count(kolom) untuk menghitung dan EXISTS untuk menguji keberadaan.

Relasi Satu-ke-Banyak Menggandakan Baris Induk #

Join menggabungkan PASANGAN, bukan entitas: satu baris pelanggan yang menemukan dua pesanan menghasilkan dua baris hasil. Skema contoh memakai domain kepercayaan section ini dengan satu tambahan kolom poin di sisi pelanggan — kolom induk yang akan menjadi korban penghitungan ganda:

sql buat-tabel.sql
-- Skema contoh: pelanggan kini punya kolom poin, pesanan 5 baris
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    poin  integer NOT NULL
);

CREATE TABLE pesanan (
    id           integer PRIMARY KEY,
    pelanggan_id integer REFERENCES pelanggan (id),
    tanggal      date NOT NULL,
    total        numeric(12, 2) NOT NULL
);

INSERT INTO pelanggan (id, nama, kota, poin) VALUES
    (1, 'Budi Santoso', 'Bandung',  100),
    (2, 'Siti Rahayu',  'Jakarta',  150),
    (3, 'Andi Wijaya',  'Surabaya',  50),
    (4, 'Dewi Lestari', 'Medan',    100);

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);

SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
 id |     nama     |   kota   | poin 
----+--------------+----------+------
  1 | Budi Santoso | Bandung  |  100
  2 | Siti Rahayu  | Jakarta  |  150
  3 | Andi Wijaya  | Surabaya |   50
  4 | Dewi Lestari | Medan    |  100
(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)

Empat pelanggan, lima pesanan. Sekarang datangi masalahnya dari dua arah — angka acuan dari tabel induk, lalu angka yang dihasilkan bila agregasi dilakukan di atas hasil join:

-- Laporan mentah: pelanggan digandakan sebanyak pesanannya
SELECT p.id, p.nama, p.poin, ps.id AS pesanan, ps.total
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.id, ps.id;

-- Angka acuan: hitungan langsung di tabel pelanggan
SELECT count(*) AS jumlah_pelanggan, sum(poin) AS total_poin
FROM pelanggan;

-- Angka dari hasil join 1:N: keduanya salah
SELECT count(*) AS jumlah_baris_join,
       count(DISTINCT p.id) AS pelanggan_unik,
       sum(p.poin) AS poin_dari_join
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id;

Output yang Dihasilkan:

text
 id |     nama     | poin | pesanan |   total   
----+--------------+------+---------+-----------
  1 | Budi Santoso |  100 |     101 | 150000.00
  1 | Budi Santoso |  100 |     102 |  75000.00
  2 | Siti Rahayu  |  150 |     103 | 220000.00
  4 | Dewi Lestari |  100 |     104 |  50000.00
(4 rows)

 jumlah_pelanggan | total_poin 
------------------+------------
                4 |        400
(1 row)

 jumlah_baris_join | pelanggan_unik | poin_dari_join 
-------------------+----------------+----------------
                 4 |              3 |            450
(1 row)

Laporan mentah memperlihatkan akarnya dengan telanjang: Budi Santoso tampil DUA baris karena memiliki dua pesanan, sehingga empat baris hasil mewakili hanya tiga pelanggan. Perbandingan angkanya:

PertanyaanJawaban benarDari hasil join 1:N
Berapa jumlah pelanggan?4count(*) = 4 (kebetulan sama, makna beda)
Berapa pelanggan yang pernah memesan?3count(DISTINCT p.id) = 3
Berapa total poin seluruh pelanggan?400sum(p.poin) = 450 — Budi dihitung dua kali

Baris terakhir adalah wujud kerusakan yang paling berbahaya: sum() pada kolom induk (p.poin) menjumlahkan salinan. Perhatikan pula baris pertama — count(*) yang kebetulan benar pada data ini; ganti satu pelanggan tanpa pesanan menjadi punya tiga pesanan, dan hitungan itu ikut bergeser tanpa ada yang berubah di tabel pelanggan. avg(p.poin) bernasab sama persis: rata-rata dihitung atas baris duplikat, bukan atas pelanggan. Aturan diagnosisnya satu kalimat: setelah join 1:N, satu baris hasil TIDAK lagi berarti satu entitas induk.

DISTINCT Menyembuhkan Tampilan, Bukan Angka #

Obat pertama yang biasanya dicoba adalah DISTINCT — dan untuk kebutuhan menampilkan daftar, obat itu memang bekerja:

-- DISTINCT untuk tampilan: tiap pelanggan tampil sekali
SELECT DISTINCT p.id, p.nama
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.id;

-- sum(DISTINCT ...) bukan obat: nilai poin yang sama ikut menyusut
SELECT sum(DISTINCT p.poin) AS poin_sum_distinct
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id;

Output yang Dihasilkan:

text
 id |     nama     
----+--------------
  1 | Budi Santoso
  2 | Siti Rahayu
  4 | Dewi Lestari
(3 rows)

 poin_sum_distinct 
-------------------
               250
(1 row)

Query pertama mengembalikan tiga baris yang benar — tetapi perhatikan batasnya: DISTINCT beroperasi pada seluruh baris hasil, sehingga daftar yang ditampilkan harus terdiri dari kolom-kolom yang TIDAK berubah per pasangan. Begitu kolom anak (misalnya ps.total) ikut dipilih, duplikasi kembali — DISTINCT p.id, p.nama, ps.total tetap menghasilkan dua baris Budi. Masalah sebenarnya muncul pada query kedua: godaan memperbaiki sum(p.poin) dengan sum(DISTINCT p.poin) menghasilkan 250 — BUKAN 400. DISTINCT di dalam agregat membuang nilai yang SAMA, bukan baris yang berasal dari pelanggan yang sama: poin Budi (100) dan poin Dewi (100) dianggap duplikat nilai sehingga salah satunya dibuang. Obat ini lebih buruk dari penyakitnya karena kesalahannya berarah acak — bergantung kebetulan nilai yang sama di data.

Solusi Standar: Agregasi di Sisi Anak Sebelum Join #

Perbaikan yang sesungguhnya mengikuti satu prinsip: susun ringkasan anak menjadi tabel yang berbaris SATU PER PELANGGAN dulu, baru digabungkan. Setelah subquery GROUP BY pelanggan_id bekerja, relasi join berubah dari satu-ke-banyak menjadi satu-ke-satu, dan tidak ada lagi baris induk yang bisa digandakan:

Diagram di atas adalah arsitektur solusinya: agregasi count/sum dijalankan pada tabel anak DI DALAM subquery, hasilnya digabungkan ke induk sebagai tabel ringkas satu-baris-per-kunci:

-- Solusi standar: agregasi anak di subquery, join 1:1 ke induk
SELECT p.id, p.nama, p.poin,
       coalesce(t.jumlah_pesanan, 0) AS jumlah_pesanan,
       t.total_belanja
FROM pelanggan p
LEFT JOIN (
    SELECT pelanggan_id,
           count(*)    AS jumlah_pesanan,
           sum(total)  AS total_belanja
    FROM pesanan
    GROUP BY pelanggan_id
) t ON t.pelanggan_id = p.id
ORDER BY p.id;

-- Sekarang agregasi kolom induk di atas hasil join menjadi sah
SELECT sum(p.poin) AS total_poin,
       sum(coalesce(t.total_belanja, 0)) AS total_belanja_pelanggan
FROM pelanggan p
LEFT JOIN (
    SELECT pelanggan_id, sum(total) AS total_belanja
    FROM pesanan
    GROUP BY pelanggan_id
) t ON t.pelanggan_id = p.id;

Output yang Dihasilkan:

text
 id |     nama     | poin | jumlah_pesanan | total_belanja 
----+--------------+------+----------------+---------------
  1 | Budi Santoso |  100 |              2 |     225000.00
  2 | Siti Rahayu  |  150 |              1 |     220000.00
  3 | Andi Wijaya  |   50 |              0 |              
  4 | Dewi Lestari |  100 |              1 |      50000.00
(4 rows)

 total_poin | total_belanja_pelanggan 
------------+-------------------------
        400 |               495000.00
(1 row)

Mari kita bedah komponen kuncinya:

  1. Subquery ( SELECT ... GROUP BY pelanggan_id ) t — membaca pesanan saja dan menghasilkan empat kelompok: ringkasan Budi, Siti, Dewi, plus satu kelompok beranggota pesanan walk-in 105 yang kuncinya NULL. GROUP BY memperlakukan NULL sebagai satu kelompok tersendiri, tetapi kelompok NULL itu tidak pernah menemukan pasangan pelanggan di join, sehingga tidak pernah masuk ke hasil akhir. Detail perilaku ini dibahas di chapter GROUP BY .
  2. LEFT JOIN ... ON t.pelanggan_id = p.id — relasinya kini 1:1, sehingga LEFT JOIN cukup untuk mempertahankan Andi (tanpa pesanan) tanpa risiko penggandaan siapa pun.
  3. coalesce(t.jumlah_pesanan, 0) — baris Andi tidak menemukan ringkasan, sehingga kolomnya NULL; coalesce menggantinya 0 untuk tampilan yang jujur (“belum ada pesanan”), sementara total_belanja dibiarkan NULL karena memang belum ada nilai belanja — pilihan yang dibahas di chapter IS NULL .
  4. sum(p.poin) pada query kedua — kini sah: setiap pelanggan muncul tepat sekali di hasil join, sehingga 400 kembali benar; total_belanja_pelanggan 495000 menjumlahkan seluruh pesanan milik pelanggan terdaftar (pesanan walk-in memang bukan belanja pelanggan).

Menghitung Anak dan Menguji Keberadaan #

Dua kebutuhan yang lebih ringan punya obat yang lebih ringan. Untuk menghitung jumlah anak per induk, pola count(kolom) dari chapter LEFT JOIN cukup — tanpa subquery, tanpa filter tambahan:

-- Menghitung anak tanpa menduplikasi induk: count(ps.id) + GROUP BY
SELECT p.id, p.nama, count(ps.id) AS jumlah_pesanan
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;

-- Uji keberadaan dengan EXISTS: pelanggan yang pernah memesan
SELECT p.id, p.nama
FROM pelanggan p
WHERE EXISTS (SELECT 1 FROM pesanan ps WHERE ps.pelanggan_id = p.id)
ORDER BY p.id;

-- Kebalikannya dengan NOT EXISTS: pelanggan yang belum pernah memesan
SELECT p.id, p.nama
FROM pelanggan p
WHERE NOT EXISTS (SELECT 1 FROM pesanan ps WHERE ps.pelanggan_id = p.id)
ORDER BY p.id;

Output yang Dihasilkan:

text
 id |     nama     | jumlah_pesanan 
----+--------------+----------------
  1 | Budi Santoso |              2
  2 | Siti Rahayu  |              1
  3 | Andi Wijaya  |              0
  4 | Dewi Lestari |              1
(4 rows)

 id |     nama     
----+--------------
  1 | Budi Santoso
  2 | Siti Rahayu
  4 | Dewi Lestari
(3 rows)

 id |    nama     
----+-------------
  3 | Andi Wijaya
(1 row)

Query pertama mengembalikan 2, 1, 0, 1 — count(ps.id) mengabaikan NULL penambal milik Andi sehingga hitungannya nol, bukan satu (perilaku count(kolom) dibahas di chapter COUNT ). Dua query berikutnya memakai EXISTS, yang bekerja dengan logika berbeda dari join: EXISTS hanya bertanya “apakah ada minimal satu pesanan untuk pelanggan ini?” dan berhenti begitu jawabannya ketemu — pelanggan tidak pernah digandakan berapa pun banyaknya pesanan, karena hasil akhirnya tetap baris-baris pelanggan. Pola ini disebut semi-join: menyaring induk berdasarkan keberadaan anak, tanpa membawa kolom anak ke hasil. NOT EXISTS adalah kebalikannya dan menjadi pesaing terkuat pola anti-join LEFT JOIN ... WHERE ps.id IS NULL yang dipakai di chapter LEFT JOIN — pembahasan penuh keduanya menunggu di section Subquery dan CTE .

Seluruh permasalahan di chapter ini dirangkum dalam satu tabel keputusan:

Kebutuhan laporanSolusiJebakan yang dihindari
Daftar entitas induk sekali sajaSELECT DISTINCT pada kolom induk sajasum/avg kolom induk di atas join
Jumlah anak per indukLEFT JOIN + count(kolom_anak) + GROUP BYcount(*) menghitung baris, bukan anak
Induk + ringkasan anak (jumlah, total)subquery GROUP BY di anak, lalu joinagregasi di atas hasil join 1:N
Menyaring induk yang punya/tidak punya anakEXISTS / NOT EXISTSjoin lalu DISTINCT

Section JOIN ditutup dengan keahlian membaca relasi dari dua arah: menyusun pasangan antartabel dengan tepat, dan menjaga agregasi tetap jujur ketika pasangan itu berjumlah banyak. Kedua solusi utama chapter ini — subquery agregat di FROM dan EXISTS di WHERE — sesungguhnya adalah pintu masuk ke dunia query di dalam query. Section berikutnya, Subquery dan CTE , membuka pintu itu lebar-lebar: scalar subquery, subquery berkorelasi, hingga Common Table Expression yang merapikan query analitik paling panjang sekalipun.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

agregasi-di-anak.sql

Unduh berkas
sql
-- Solusi standar: agregasi anak di subquery, join 1:1 ke induk
SELECT p.id, p.nama, p.poin,
       coalesce(t.jumlah_pesanan, 0) AS jumlah_pesanan,
       t.total_belanja
FROM pelanggan p
LEFT JOIN (
    SELECT pelanggan_id,
           count(*)    AS jumlah_pesanan,
           sum(total)  AS total_belanja
    FROM pesanan
    GROUP BY pelanggan_id
) t ON t.pelanggan_id = p.id
ORDER BY p.id;

-- Sekarang agregasi kolom induk di atas hasil join menjadi sah
SELECT sum(p.poin) AS total_poin,
       sum(coalesce(t.total_belanja, 0)) AS total_belanja_pelanggan
FROM pelanggan p
LEFT JOIN (
    SELECT pelanggan_id, sum(total) AS total_belanja
    FROM pesanan
    GROUP BY pelanggan_id
) t ON t.pelanggan_id = p.id;

buat-tabel.sql

Unduh berkas
sql
-- Skema contoh: pelanggan kini punya kolom poin, pesanan 5 baris
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    poin  integer NOT NULL
);

CREATE TABLE pesanan (
    id           integer PRIMARY KEY,
    pelanggan_id integer REFERENCES pelanggan (id),
    tanggal      date NOT NULL,
    total        numeric(12, 2) NOT NULL
);

INSERT INTO pelanggan (id, nama, kota, poin) VALUES
    (1, 'Budi Santoso', 'Bandung',  100),
    (2, 'Siti Rahayu',  'Jakarta',  150),
    (3, 'Andi Wijaya',  'Surabaya',  50),
    (4, 'Dewi Lestari', 'Medan',    100);

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);

SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;

distinct-terbatas.sql

Unduh berkas
sql
-- DISTINCT untuk tampilan: tiap pelanggan tampil sekali
SELECT DISTINCT p.id, p.nama
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.id;

-- sum(DISTINCT ...) bukan obat: nilai poin yang sama ikut menyusut
SELECT sum(DISTINCT p.poin) AS poin_sum_distinct
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id;

hitung-dan-exists.sql

Unduh berkas
sql
-- Menghitung anak tanpa menduplikasi induk: count(ps.id) + GROUP BY
SELECT p.id, p.nama, count(ps.id) AS jumlah_pesanan
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;

-- Uji keberadaan dengan EXISTS: pelanggan yang pernah memesan
SELECT p.id, p.nama
FROM pelanggan p
WHERE EXISTS (SELECT 1 FROM pesanan ps WHERE ps.pelanggan_id = p.id)
ORDER BY p.id;

-- Kebalikannya dengan NOT EXISTS: pelanggan yang belum pernah memesan
SELECT p.id, p.nama
FROM pelanggan p
WHERE NOT EXISTS (SELECT 1 FROM pesanan ps WHERE ps.pelanggan_id = p.id)
ORDER BY p.id;

masalah-inflasi.sql

Unduh berkas
sql
-- Laporan mentah: pelanggan digandakan sebanyak pesanannya
SELECT p.id, p.nama, p.poin, ps.id AS pesanan, ps.total
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.id, ps.id;

-- Angka acuan: hitungan langsung di tabel pelanggan
SELECT count(*) AS jumlah_pelanggan, sum(poin) AS total_poin
FROM pelanggan;

-- Angka dari hasil join 1:N: keduanya salah
SELECT count(*) AS jumlah_baris_join,
       count(DISTINCT p.id) AS pelanggan_unik,
       sum(p.poin) AS poin_dari_join
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id;