Topik ini menyegarkan SQL dari sisi yang paling sering menjebak pekerjaan data engineering, yaitu perilaku NULL, jebakan join, dan agregasi melampaui bentuk dasarnya. Seluruh contoh dijalankan dengan Python 3.12 dan DuckDB 2.0 melalui skrip kecil yang membangun basis data contoh di direktori kerja. Setiap skrip menampilkan keluaran aslinya, sehingga perilaku yang dijelaskan dapat diverifikasi ulang kapan pun. Setelah membaca, query harian dapat ditulis dengan lebih waspada terhadap baris yang tidak terlihat.
Dataset contoh#
Dataset yang dipakai di sepanjang topik ini adalah catatan penjualan kecil sebuah kedai kopi yang tersimpan dalam dua tabel. Tabel pelanggan menyimpan anggota dengan kolom id, nama, dan kota, sedangkan tabel pesanan menyimpan id, pelanggan_id, tanggal, dan total. Nilai NULL muncul secara alami di dua tempat, yaitu kota pelanggan yang belum tercatat dan pelanggan_id pada pembelian tanpa keanggotaan. Skrip berikut membangun ulang basis data kopi.duckdb dari nol setiap kali dijalankan, sehingga isi datanya selalu sama.
import os
import duckdb
# basis data kecil dibangun ulang setiap kali skrip dijalankan
if os.path.exists("kopi.duckdb"):
os.remove("kopi.duckdb")
con = duckdb.connect("kopi.duckdb")
con.execute("""
CREATE TABLE pelanggan (
id INTEGER PRIMARY KEY,
nama VARCHAR NOT NULL,
kota VARCHAR
)
""")
con.execute("""
INSERT INTO pelanggan VALUES
(1, 'Ali', 'Bandung'),
(2, 'Budi', 'Jakarta'),
(3, 'Citra', 'Bandung'),
(4, 'Dewi', NULL),
(5, 'Eka', 'Surabaya')
""")
con.execute("""
CREATE TABLE pesanan (
id INTEGER PRIMARY KEY,
pelanggan_id INTEGER,
tanggal DATE,
total DECIMAL(10, 2)
)
""")
con.execute("""
INSERT INTO pesanan VALUES
(101, 1, DATE '2024-05-01', 25000),
(102, 1, DATE '2024-05-03', 18000),
(103, 2, DATE '2024-05-03', 32000),
(104, NULL, DATE '2024-05-04', 15000),
(105, 3, DATE '2024-05-05', 27000),
(106, NULL, DATE '2024-05-06', 22000),
(107, 4, DATE '2024-05-07', 19000),
(108, 5, DATE '2024-05-09', 41000)
""")
for tabel in ("pelanggan", "pesanan"):
jumlah = con.execute(f"SELECT COUNT(*) FROM {tabel}").fetchone()[0]
print(f"{tabel}: {jumlah} baris")
con.close()pelanggan: 5 baris
pesanan: 8 barisDua angka pada keluaran menjadi patokan bagi seluruh topik, yaitu lima baris pelanggan dan delapan baris pesanan. Pada tabel pesanan, dua baris tidak memiliki pelanggan_id dan satu baris merujuk pelanggan yang kotanya tidak tercatat. Ketiga baris itulah yang akan sering muncul dan hilang pada contoh-contoh berikutnya.
Perilaku NULL pada perbandingan#
NULL bukanlah nilai, melainkan penanda bahwa nilai itu tidak ada, sehingga setiap perbandingan yang menyentuh NULL menghasilkan NULL dan bukan benar atau salah. Konsekuensinya, baris dengan kota NULL tidak pernah lolos pada penyaringan kota = 'Bandung', dan tidak lolos pula pada penyaringan kebalikannya. Kaidah ini disebut logika tiga nilai (three-valued logic), yakni logika yang mengenal benar, salah, dan tidak diketahui. Skrip berikut menunjukkan empat perilaku yang paling sering mengagetkan secara berurutan.
import duckdb
def tampilkan(con, sql):
"""Cetak hasil query sebagai tabel rapi berkolom sejajar."""
hasil = con.execute(sql)
kolom = [d[0] for d in hasil.description]
baris = hasil.fetchall()
lebar = []
for i in range(len(kolom)):
anggota = [len(kolom[i])] + [len(str(b[i])) for b in baris]
lebar.append(max(anggota))
fmt = " ".join(f"{{:<{w}}}" for w in lebar)
print(fmt.format(*kolom).rstrip())
for b in baris:
print(fmt.format(*["NULL" if v is None else str(v) for v in b]).rstrip())
con = duckdb.connect("kopi.duckdb")
print("1) WHERE kota = 'Bandung': baris dengan kota NULL tidak ikut")
tampilkan(con, "SELECT id, nama, kota FROM pelanggan WHERE kota = 'Bandung'")
print()
print("2) WHERE kota <> 'Bandung': NULL juga tidak lolos")
jumlah = con.execute("SELECT COUNT(*) FROM pelanggan WHERE kota <> 'Bandung'").fetchone()[0]
print(f" baris yang lolos: {jumlah}")
print()
print("3) IS NULL menangkap baris yang tadi hilang")
tampilkan(con, "SELECT id, nama FROM pelanggan WHERE kota IS NULL")
print()
print("4) COALESCE mengganti NULL dengan label yang mudah dibaca")
tampilkan(con, "SELECT id, nama, COALESCE(kota, 'tidak tercatat') AS kota FROM pelanggan")
con.close()1) WHERE kota = 'Bandung': baris dengan kota NULL tidak ikut
id nama kota
1 Ali Bandung
3 Citra Bandung
2) WHERE kota <> 'Bandung': NULL juga tidak lolos
baris yang lolos: 2
3) IS NULL menangkap baris yang tadi hilang
id nama
4 Dewi
4) COALESCE mengganti NULL dengan label yang mudah dibaca
id nama kota
1 Ali Bandung
2 Budi Jakarta
3 Citra Bandung
4 Dewi tidak tercatat
5 Eka SurabayaPada percobaan pertama, hanya Ali dan Citra yang muncul, sedangkan Dewi yang kotanya NULL menghilang tanpa pesan apa pun. Percobaan kedua memperlihatkan kasus yang lebih sering terlewat, yaitu pencarian kebalikannya juga kehilangan Dewi, sehingga baris yang lolos hanya dua dari lima. Percobaan ketiga memakai IS NULL, satu-satunya cara untuk menguji ketiadaan nilai, dan Dewi kembali ditemukan. Percobaan keempat memakai fungsi COALESCE yang mengembalikan argumen pertama yang bukan NULL, sehingga laporan dapat menampilkan label baca alih-alih sel kosong.
Perbedaan COUNT(*) dan COUNT(kolom)#
COUNT(*) menghitung seluruh baris hasil, sedangkan COUNT(kolom) hanya menghitung baris yang kolomnya tidak bernilai NULL. Perbedaan ini membuat kedua bentuk COUNT memberi jawaban yang berbeda pada tabel yang sama. Skrip berikut menghitung keduanya sekaligus pada tabel pesanan, ditambah COUNT(DISTINCT ...) yang hanya menghitung nilai unik.
import duckdb
def tampilkan(con, sql):
"""Cetak hasil query sebagai tabel rapi berkolom sejajar."""
hasil = con.execute(sql)
kolom = [d[0] for d in hasil.description]
baris = hasil.fetchall()
lebar = []
for i in range(len(kolom)):
anggota = [len(kolom[i])] + [len(str(b[i])) for b in baris]
lebar.append(max(anggota))
fmt = " ".join(f"{{:<{w}}}" for w in lebar)
print(fmt.format(*kolom).rstrip())
for b in baris:
print(fmt.format(*["NULL" if v is None else str(v) for v in b]).rstrip())
con = duckdb.connect("kopi.duckdb")
tampilkan(con, """
SELECT COUNT(*) AS baris_total,
COUNT(pelanggan_id) AS baris_beranggota,
COUNT(DISTINCT pelanggan_id) AS anggota_unik,
SUM(total) AS omzet
FROM pesanan
""")
con.close()baris_total baris_beranggota anggota_unik omzet
8 6 5 199000.00Kolom baris_total menunjukkan 8, kolom baris_beranggota menunjukkan 6, dan selisih keduanya persis dua pesanan tanpa keanggotaan. Kolom anggota_unik menunjukkan 5 karena Ali melakukan dua pesanan, sehingga COUNT(DISTINCT pelanggan_id) menghitungnya sekali. Fungsi agregat lain mengikuti kaidah yang sama, yaitu SUM, AVG, MIN, dan MAX melewati baris NULL, sehingga kolom omzet tetap terisi dari baris yang punya nilai. Membandingkan COUNT(*) dengan COUNT(kolom) juga merupakan pengukur kelengkapan kolom yang murah sebelum analisis lain dijalankan.
Jebakan LEFT JOIN dengan filter pada tabel kanan#
Menaruh syarat pada kolom tabel kanan di dalam WHERE mengubah LEFT JOIN menjadi INNER JOIN secara efektif. Penyebabnya sama dengan bagian sebelumnya, yaitu baris tanpa pasangan membawa NULL pada semua kolom tabel kanan, lalu syarat yang melibatkan NULL tidak pernah bernilai benar. Skrip berikut membandingkan tiga bentuk query yang sekilas terlihat setara.
import duckdb
def tampilkan(con, sql):
"""Cetak hasil query sebagai tabel rapi berkolom sejajar."""
hasil = con.execute(sql)
kolom = [d[0] for d in hasil.description]
baris = hasil.fetchall()
lebar = []
for i in range(len(kolom)):
anggota = [len(kolom[i])] + [len(str(b[i])) for b in baris]
lebar.append(max(anggota))
fmt = " ".join(f"{{:<{w}}}" for w in lebar)
print(fmt.format(*kolom).rstrip())
for b in baris:
print(fmt.format(*["NULL" if v is None else str(v) for v in b]).rstrip())
con = duckdb.connect("kopi.duckdb")
print("a) LEFT JOIN tanpa filter tabel kanan: 8 pesanan tetap muncul")
tampilkan(con, """
SELECT s.id, s.pelanggan_id, p.kota, s.total
FROM pesanan s
LEFT JOIN pelanggan p ON p.id = s.pelanggan_id
""")
print()
print("b) LEFT JOIN plus WHERE kota = 'Bandung': sisanya terbuang")
tampilkan(con, """
SELECT s.id, s.pelanggan_id, p.kota, s.total
FROM pesanan s
LEFT JOIN pelanggan p ON p.id = s.pelanggan_id
WHERE p.kota = 'Bandung'
""")
print()
print("c) Perbaikannya: syarat kota dipindah ke ON")
tampilkan(con, """
SELECT s.id, s.pelanggan_id, p.kota, s.total
FROM pesanan s
LEFT JOIN pelanggan p
ON p.id = s.pelanggan_id
AND p.kota = 'Bandung'
""")
con.close()a) LEFT JOIN tanpa filter tabel kanan: 8 pesanan tetap muncul
id pelanggan_id kota total
101 1 Bandung 25000.00
102 1 Bandung 18000.00
103 2 Jakarta 32000.00
104 NULL NULL 15000.00
105 3 Bandung 27000.00
106 NULL NULL 22000.00
107 4 NULL 19000.00
108 5 Surabaya 41000.00
b) LEFT JOIN plus WHERE kota = 'Bandung': sisanya terbuang
id pelanggan_id kota total
101 1 Bandung 25000.00
102 1 Bandung 18000.00
105 3 Bandung 27000.00
c) Perbaikannya: syarat kota dipindah ke ON
id pelanggan_id kota total
101 1 Bandung 25000.00
102 1 Bandung 18000.00
103 2 NULL 32000.00
104 NULL NULL 15000.00
105 3 Bandung 27000.00
106 NULL NULL 22000.00
107 4 NULL 19000.00
108 5 NULL 41000.00Percobaan pertama mengembalikan seluruh delapan pesanan, lengkap dengan dua pesanan tanpa anggota yang kolom kotanya NULL. Percobaan kedua memindahkan keinginan menampilkan pesanan pelanggan Bandung ke dalam WHERE, sehingga hasilnya tinggal tiga baris. Sisanya terbuang diam-diam, yaitu dua pesanan tanpa anggota, pesanan dari kota lain, dan pesanan milik Dewi. Perilaku ini sering lolos dari pemeriksaan karena tidak ada pesan error dan angkanya masih terlihat masuk akal. Percobaan ketiga memperlihatkan perbaikannya, yaitu syarat kota dipindahkan ke dalam ON sehingga penyaringan terjadi sebelum baris tanpa pasangan dilengkapi NULL, dan seluruh delapan pesanan kembali muncul.
GROUP BY, HAVING, dan kelompok NULL#
Agregasi memadatkan banyak baris menjadi satu baris per kelompok, dan semua baris yang kolom pengelompokannya NULL masuk ke satu kelompok yang sama. Aturan kedua yang sering keliru adalah letak penyaringannya, yaitu WHERE menyaring baris sebelum agregasi, sedangkan HAVING menyaring kelompok sesudah agregasi. Skrip berikut menggabungkan pesanan dengan pelanggan, lalu menghitung omzet per kota dalam tiga percobaan, dan dua percobaan pertama diurutkan dengan ORDER BY omzet DESC agar urutan keluarannya tetap.
import duckdb
def tampilkan(con, sql):
"""Cetak hasil query sebagai tabel rapi berkolom sejajar."""
hasil = con.execute(sql)
kolom = [d[0] for d in hasil.description]
baris = hasil.fetchall()
lebar = []
for i in range(len(kolom)):
anggota = [len(kolom[i])] + [len(str(b[i])) for b in baris]
lebar.append(max(anggota))
fmt = " ".join(f"{{:<{w}}}" for w in lebar)
print(fmt.format(*kolom).rstrip())
for b in baris:
print(fmt.format(*["NULL" if v is None else str(v) for v in b]).rstrip())
con = duckdb.connect("kopi.duckdb")
print("1) GROUP BY membentuk satu kelompok dari semua kota NULL")
tampilkan(con, """
SELECT p.kota, COUNT(*) AS jumlah_pesanan, SUM(s.total) AS omzet
FROM pesanan s
LEFT JOIN pelanggan p ON p.id = s.pelanggan_id
GROUP BY p.kota
ORDER BY omzet DESC
""")
print()
print("2) HAVING menyaring hasil agregasi per kelompok")
tampilkan(con, """
SELECT p.kota, COUNT(*) AS jumlah_pesanan, SUM(s.total) AS omzet
FROM pesanan s
LEFT JOIN pelanggan p ON p.id = s.pelanggan_id
GROUP BY p.kota
HAVING SUM(s.total) > 40000
ORDER BY omzet DESC
""")
print()
print("3) WHERE yang memuat fungsi agregat ditolak oleh mesin SQL")
try:
con.execute("SELECT pelanggan_id, SUM(total) AS omzet FROM pesanan WHERE SUM(total) > 40000 GROUP BY pelanggan_id")
except Exception as e:
print(f"{type(e).__name__}: {e}")
con.close()1) GROUP BY membentuk satu kelompok dari semua kota NULL
kota jumlah_pesanan omzet
Bandung 3 70000.00
NULL 3 56000.00
Surabaya 1 41000.00
Jakarta 1 32000.00
2) HAVING menyaring hasil agregasi per kelompok
kota jumlah_pesanan omzet
Bandung 3 70000.00
NULL 3 56000.00
Surabaya 1 41000.00
3) WHERE yang memuat fungsi agregat ditolak oleh mesin SQL
BinderException: Binder Error: WHERE clause cannot contain aggregates!
LINE 1: ...ELECT pelanggan_id, SUM(total) AS omzet FROM pesanan WHERE SUM(total) > 40000 GROUP BY pelanggan_id
^^^^^^^^^^Percobaan pertama menghasilkan kelompok NULL dengan tiga pesanan dan omzet 56000.00, yaitu gabungan dua pesanan tanpa anggota dan satu pesanan milik Dewi. Kelompok ini sah menurut standar SQL dan tidak menghilang seperti pada penyaringan baris, sehingga laporan per kota harus memutuskan secara sadar apakah kelompok tanpa kota ditampilkan, dilabeli dengan COALESCE, atau disaring dengan WHERE kota IS NOT NULL. Percobaan kedua menambahkan HAVING SUM(s.total) > 40000, sehingga hanya Bandung, kelompok NULL, dan Surabaya yang lolos. Percobaan ketiga menunjukkan sisi lain dari aturan letaknya, yaitu WHERE yang memuat SUM langsung ditolak binder, komponen mesin SQL yang memeriksa keabsahan query, dengan pesan error yang jelas.
Kesalahan umum#
Empat kekeliruan berikut paling sering muncul pada pekerjaan SQL harian, beserta penyebab dan perbaikannya.
- Membandingkan kolom dengan NULL menggunakan tanda sama dengan. Perbandingan itu tidak pernah benar karena hasilnya NULL, sehingga baris yang dicari tidak muncul. Gunakan
IS NULLatauIS NOT NULL. - Menyaring kolom tabel kanan di dalam WHERE sesudah LEFT JOIN. Syarat itu membuang baris tanpa pasangan dan mengubah join menjadi INNER JOIN secara efektif. Pindahkan syaratnya ke dalam
ON. - Menghitung nilai terisi dengan
COUNT(*). Bentuk ini menghitung seluruh baris termasuk yang NULL, sehingga angkanya terlalu besar. GunakanCOUNT(kolom)atauCOUNT(DISTINCT kolom)sesuai maksud. - Menyaring hasil agregasi dengan WHERE. Fungsi agregat di dalam WHERE ditolak binder saat query diperiksa. Gunakan HAVING untuk menyaring kelompok hasil agregasi.
Ringkasan#
- NULL tidak pernah lolos perbandingan biasa, baik pada pencarian maupun pengecualiannya, sehingga
IS NULLdanCOALESCEwajib disiapkan setiap kali kolom boleh NULL. COUNT(*)menghitung semua baris,COUNT(kolom)menghitung baris terisi, dan selisih keduanya mengukur kelengkapan kolom.- Syarat pada kolom tabel kanan di dalam WHERE mengubah LEFT JOIN menjadi INNER JOIN secara efektif, sedangkan penempatannya di dalam
ONmempertahankan baris kiri. - WHERE menyaring baris sebelum agregasi dan tidak boleh memuat fungsi agregat, sedangkan HAVING menyaring kelompok sesudahnya, dan baris NULL membentuk satu kelompok tersendiri.
Referensi#
- NULL Values — DuckDB — Kaidah perbandingan dengan NULL serta fungsi IS NULL dan COALESCE pada DuckDB.
- Aggregate Functions — DuckDB — Daftar fungsi agregat bawaan DuckDB beserta sintaks pemakaiannya.
- FROM and JOIN — DuckDB — Semantik join pada DuckDB, termasuk perilaku OUTER JOIN yang menjadi dasar jebakan di atas.
- HAVING — DuckDB — Aturan penyaringan kelompok hasil agregasi dengan klausa HAVING.