Semantik NULL di SQL: Propagasi NULL di Agregasi dan JOIN
TL;DR
NULL di SQL artinya nilai yang nggak diketahui, bukan nol dan bukan string kosong. Perbandingan apa pun dengan NULL menghasilkan UNKNOWN, jadi NULL = NULL nggak pernah bernilai benar dan baris dengan NULL nggak pernah kejoin lewat operator sama dengan. Fungsi agregasi kayak SUM dan AVG ngelewatin baris NULL, jadi AVG ngebagi total sama jumlah baris yang ada isinya, bukan jumlah seluruh baris.
NULL di SQL artinya nilai yang nggak diketahui, jadi bukan nol dan bukan string kosong.
Akibatnya satu: perbandingan apa pun sama NULL keluar UNKNOWN, bukan benar dan bukan salah. Dari satu aturan itu lahir semua perilaku aneh yang bikin angka laporan kamu meleset.
Di bawah ini gimana NULL nyebar di agregasi, di JOIN, dan di NOT IN, plus query yang bisa kamu tempel buat ngecek data sendiri.
Apa itu NULL di SQL?
NULL adalah penanda kalau sebuah kolom nggak punya nilai. Bukan nol, bukan spasi, bukan string kosong. Nol itu angka yang kamu tau nilainya. NULL itu kondisi kamu nggak tau nilainya berapa.
Bedanya kelihatan pas dipakai:
SELECT 0 = 0; -- true
SELECT NULL = NULL; -- NULL (unknown)
SELECT '' = NULL; -- NULL (unknown)
SELECT NULL IS NULL; -- true
Baris kedua itu sumber semua masalahnya. Dua nilai yang sama-sama nggak diketahui nggak bisa dibilang sama, soalnya kamu nggak tau isinya apa.
Makanya buat ngecek NULL kamu wajib pakai IS NULL, bukan = NULL. Query dengan WHERE kolom = NULL selalu balikin nol baris, tanpa pesan error.
Kenapa NULL bikin logika SQL punya tiga nilai?
SQL pakai three-valued logic: TRUE, FALSE, dan UNKNOWN. Klausa WHERE cuma nerusin baris yang hasilnya TRUE. Baris yang hasilnya UNKNOWN kebuang bareng yang FALSE, dan ini yang bikin orang ngira query-nya jalan padahal ada baris yang diem-diem ilang.
| Ekspresi | Hasil |
|---|---|
| TRUE AND UNKNOWN | UNKNOWN |
| FALSE AND UNKNOWN | FALSE |
| TRUE OR UNKNOWN | TRUE |
| FALSE OR UNKNOWN | UNKNOWN |
| NOT UNKNOWN | UNKNOWN |
Perhatiin baris kedua dan ketiga. FALSE AND UNKNOWN tetap FALSE, dan TRUE OR UNKNOWN tetap TRUE. Di dua kasus itu hasilnya udah pasti sebelum bagian UNKNOWN dibaca.
Contoh yang sering nyakitin:
-- Niatnya ambil semua transaksi yang bukan promo A
SELECT * FROM transaksi WHERE kode_promo <> 'PROMO_A';
-- Baris dengan kode_promo NULL nggak ikut kebawa,
-- padahal secara logika bisnis mereka juga bukan promo A.
Aturan lengkap perbandingan dan operator NULL ada di dokumentasi PostgreSQL soal comparison functions.
Gimana NULL diperlakukan di fungsi agregasi?
Semua fungsi agregasi standar ngelewatin NULL, kecuali COUNT(*). SUM, AVG, MIN, dan MAX cuma ngitung baris yang ada isinya. COUNT(*) ngitung baris, COUNT(kolom) ngitung nilai yang bukan NULL di kolom itu.
| Fungsi | Perlakuan ke NULL | Kalau semua baris NULL |
|---|---|---|
| COUNT(*) | Baris tetap dihitung | Jumlah baris |
| COUNT(kolom) | Dilewatin | 0 |
| SUM(kolom) | Dilewatin | NULL, bukan 0 |
| AVG(kolom) | Dilewatin dari pembilang dan penyebut | NULL |
| MIN / MAX | Dilewatin | NULL |
| COUNT(DISTINCT kolom) | Dilewatin | 0 |
Baris SUM itu jebakan buat dashboard. SUM dari tabel kosong ngasih NULL, dan kalau angkanya langsung ditampilin, kartu KPI kamu blank alih-alih nulis nol.
-- Aman buat dashboard
SELECT COALESCE(SUM(total), 0) AS omzet
FROM transaksi
WHERE tanggal = CURRENT_DATE;
Penjelasan umum soal fungsi ini ada di glossary aggregate function.
Kenapa AVG ngasih angka lebih besar dari yang kamu kira?
Karena AVG ngebagi total sama jumlah baris yang ada isinya, bukan jumlah seluruh baris. Kalau kamu maksudnya NULL itu artinya nol, hasil AVG bawaan bakal lebih tinggi dari rata-rata yang kamu bayangin. Bedanya makin lebar seiring makin banyaknya baris kosong.
SELECT
COUNT(*) AS total_baris,
COUNT(diskon) AS baris_ada_isi,
AVG(diskon) AS avg_abaikan_null,
SUM(diskon) / COUNT(*) AS avg_null_jadi_nol
FROM transaksi;
Mana yang bener? Tergantung arti NULL di kolom itu.
Kalau NULL artinya transaksi ini nggak dapat diskon, NULL sama dengan nol dan kamu harus pakai AVG(COALESCE(diskon, 0)). Kalau NULL artinya datanya kelewat diinput, ngelewatin baris itu lebih jujur.
Keputusannya bukan soal teknis, tapi soal arti kolomnya. Ini bagian dari kerjaan njaga kualitas data yang jarang ditulis di dokumentasi.
Apa yang terjadi ke NULL di JOIN?
Baris dengan NULL di kolom kunci nggak pernah kejoin lewat operator sama dengan, soalnya NULL = NULL hasilnya UNKNOWN. Jadi kalau tabel kiri punya 200 baris dengan kode promo kosong, semua baris itu ilang di INNER JOIN tanpa ada peringatan apa pun.
-- 200 baris berkode NULL bakal ilang di sini
SELECT t.id, p.nama_promo
FROM transaksi t
JOIN promo p ON t.kode_promo = p.kode_promo;
Kalau kamu emang mau nyocokin NULL sama NULL, PostgreSQL punya operator khusus:
SELECT t.id, p.nama_promo
FROM transaksi t
JOIN promo p
ON t.kode_promo IS NOT DISTINCT FROM p.kode_promo;
Di database yang nggak punya operator itu, pakai COALESCE dengan nilai sentinel yang mustahil muncul di data asli:
ON COALESCE(t.kode_promo, '__KOSONG__') = COALESCE(p.kode_promo, '__KOSONG__')
Hati-hati, cara ini bikin index di kolom itu nggak kepakai, jadi query-nya melambat di tabel besar.
Masalah kedua muncul di LEFT JOIN. Filter di WHERE yang nyentuh kolom tabel kanan bakal ngubah LEFT JOIN kamu jadi INNER JOIN diam-diam:
-- Salah: baris tanpa pasangan kebuang
SELECT c.nama, t.total
FROM pelanggan c
LEFT JOIN transaksi t ON t.pelanggan_id = c.id
WHERE t.tanggal >= '2025-08-01';
-- Benar: syaratnya ditaruh di ON
SELECT c.nama, t.total
FROM pelanggan c
LEFT JOIN transaksi t
ON t.pelanggan_id = c.id
AND t.tanggal >= '2025-08-01';
Kenapa? Karena baris yang nggak punya pasangan dikasih NULL di semua kolom tabel kanan, dan NULL >= '2025-08-01' hasilnya UNKNOWN, jadi kebuang WHERE.
Masalah JOIN yang lain, yaitu baris yang malah kelipatan, dibahas di kenapa JOIN melipatgandakan baris.
Kenapa NOT IN balikin nol baris kalau ada NULL?
Ini jebakan NULL paling mahal. Kalau subquery di dalam NOT IN ngasih satu aja nilai NULL, seluruh query balikin nol baris, tanpa error dan tanpa peringatan.
-- Kalau ada satu saja pelanggan_id NULL di transaksi,
-- hasilnya nol baris. Selalu.
SELECT * FROM pelanggan
WHERE id NOT IN (SELECT pelanggan_id FROM transaksi);
Alasannya begini. x NOT IN (a, b, NULL) dijabarin jadi x <> a AND x <> b AND x <> NULL. Bagian terakhir hasilnya UNKNOWN, dan TRUE AND UNKNOWN keluar UNKNOWN. Nggak ada baris yang lolos.
Gantinya pakai NOT EXISTS, yang aman terhadap NULL:
SELECT c.*
FROM pelanggan c
WHERE NOT EXISTS (
SELECT 1 FROM transaksi t WHERE t.pelanggan_id = c.id
);
Kebalikannya, IN biasa nggak kena masalah ini. Yang bahaya cuma versi NOT-nya.
Gimana NULL kelakuannya di GROUP BY, DISTINCT, dan ORDER BY?
Di sini aturannya beda dari WHERE, dan ini yang bikin orang bingung.
GROUP BY nganggep semua NULL itu satu kelompok yang sama. DISTINCT juga nganggep dua NULL sebagai duplikat, jadi cuma nyisain satu.
-- Semua baris berkode NULL masuk ke satu grup
SELECT kode_promo, COUNT(*)
FROM transaksi
GROUP BY kode_promo;
Kelihatan bertentangan sama NULL = NULL yang UNKNOWN tadi, dan emang gitu. Standar SQL sengaja bikin pengelompokan pakai aturan kesamaan yang beda dari operator perbandingan.
Buat ORDER BY, urutannya beda per database. PostgreSQL naruh NULL di paling belakang buat ASC. MySQL naruh NULL di paling depan buat ASC. Kalau kamu butuh hasil yang sama di dua-duanya, tulis eksplisit:
ORDER BY diskon ASC NULLS LAST;
Satu lagi yang sering kelupaan: kolom dengan constraint UNIQUE tetap boleh punya banyak baris NULL di mayoritas database, soalnya dua NULL nggak dianggap sama. Kalau kamu ngandelin UNIQUE buat nyegah data dobel, isi kolomnya wajib NOT NULL.
Contoh kasus: kolom diskon di transaksi toko_berkah
Tabel transaksi toko_berkah punya 4.820 baris untuk periode Januari sampai Agustus 2025. Kolom diskon nyimpen potongan harga dalam rupiah, dan diisi cuma pas kasir masukin kode promo.
SELECT
COUNT(*) AS total_baris, -- 4.820
COUNT(diskon) AS ada_isi, -- 4.207
COUNT(*) - COUNT(diskon) AS kosong, -- 613
AVG(diskon) AS avg_bawaan, -- 8.240
SUM(diskon) / COUNT(*) AS avg_null_jadi_nol -- 7.192
FROM transaksi;
Ada 613 baris tanpa diskon, atau 12,7 persen dari total.
AVG bawaan ngasih Rp 8.240 per transaksi. Kalau NULL diartiin nol, rata-ratanya Rp 7.192. Selisihnya 14,6 persen.
Di laporan bulanan, angka itu dipakai buat ngitung total beban diskon setahun. Selisih 14,6 persen pada 4.820 transaksi setara Rp 5,05 juta yang salah masuk perkiraan anggaran promo.
Yang bener yang mana? Di kasus ini, NULL artinya transaksi tanpa promo, jadi nilainya emang nol. Query yang dipakai buat laporan harusnya:
SELECT AVG(COALESCE(diskon, 0)) AS rata_diskon_per_transaksi
FROM transaksi;
Perbaikan jangka panjangnya bukan di query. Kolom itu dikasih DEFAULT 0 dan NOT NULL, jadi masalahnya nggak balik lagi enam bulan kemudian.
Cara aman nangani NULL di query kamu
- Hitung dulu berapa banyak NULL-nya. Jalanin
SELECT COUNT(*) - COUNT(kolom) FROM tabelsebelum nulis agregasi apa pun. - Putusin arti NULL-nya. Nol atau nggak diketahui? Dua arti ini butuh perlakuan beda.
- Pakai COALESCE cuma kalau NULL emang berarti nol. Nutupin data yang beneran hilang dengan nol itu ngerusak angkanya.
- Ganti semua NOT IN jadi NOT EXISTS. Nggak ada ruginya, dan kamu kebal dari jebakan nol baris.
- Taruh filter tabel kanan di ON, bukan WHERE, buat LEFT JOIN.
- Bungkus perbandingan yang harus nerima NULL pakai IS NOT DISTINCT FROM.
- Kasih NOT NULL dan DEFAULT di kolom yang seharusnya selalu keisi. Ini benteng paling murah, dipasang sekali di level tabel.
Buat pipeline yang narik data antar sistem, aturan penanganan NULL wajib ditulis di dokumen ETL kamu. Sistem sumber yang ngirim string kosong sering berubah jadi NULL di tujuan, atau sebaliknya.
Kesalahan umum soal NULL
1. Pakai = NULL. Selalu nol baris, nggak pernah error. Pakai IS NULL.
2. Ngira SUM dari tabel kosong itu nol. Hasilnya NULL. Bungkus pakai COALESCE kalau angkanya masuk dashboard.
3. Nyamain string kosong sama NULL. Di PostgreSQL dua-duanya beda. Di Oracle, string kosong justru disimpan sebagai NULL. Kalau datamu pindah antar database, cek ini duluan.
4. Ngandelin UNIQUE buat kolom nullable. Sepuluh baris NULL bisa masuk semua tanpa dicegah.
5. Nulis WHERE kolom <> 'X' dan ngira baris NULL ikut. Mereka nggak ikut. Tulis WHERE kolom IS DISTINCT FROM 'X' atau tambahin OR kolom IS NULL.
6. Ngitung persentase dari COUNT(kolom) tapi pembaginya COUNT(*). Dua angka itu beda kalau ada NULL, dan hasilnya bisa lewat 100 persen.
FAQ
Apa bedanya NULL sama nol dan string kosong?
Nol itu angka yang nilainya kamu tau. String kosong itu teks sepanjang nol karakter yang juga kamu tau isinya. NULL artinya nilainya nggak diketahui sama sekali. Karena itu nol dan string kosong bisa dibandingin pakai tanda sama dengan, sementara NULL nggak bisa. Di PostgreSQL ketiganya beda, tapi di Oracle string kosong disimpan sebagai NULL.
Kenapa COUNT(*) sama COUNT(kolom) hasilnya beda?
COUNT(*) ngitung jumlah baris tanpa peduli isinya, sedangkan COUNT(kolom) cuma ngitung baris yang kolom itu ada isinya. Selisih dua angka itu persis jumlah NULL di kolom tersebut. Trik ini paling cepat buat ngecek seberapa bolong sebuah kolom: tulis COUNT(*) minus COUNT(kolom) dalam satu query.
Kenapa NOT IN kasih nol baris padahal datanya ada?
Karena ada nilai NULL di dalam daftar subquery-nya. NOT IN dijabarin jadi rangkaian perbandingan nggak sama dengan yang digabung pakai AND, dan perbandingan dengan NULL menghasilkan UNKNOWN. Satu UNKNOWN aja bikin seluruh kondisi jadi UNKNOWN, jadi nggak ada baris yang lolos. Ganti pakai NOT EXISTS yang kebal masalah ini.
Kapan sebaiknya pakai COALESCE?
Pakai COALESCE pas NULL di kolom itu emang berarti nol atau punya nilai default yang jelas, misalnya diskon kosong berarti nggak ada potongan. Jangan pakai COALESCE buat nutupin data yang beneran hilang, kayak umur pelanggan yang nggak keisi. Ngisi angka karangan di situ bikin rata-ratamu salah dan kesalahannya susah dilacak belakangan.
Apakah NULL bikin query jadi lambat?
NULL sendiri nggak bikin lambat, tapi cara nanganinya bisa. Kalau kamu bungkus kolom pakai COALESCE di klausa ON atau WHERE, database sering nggak bisa pakai index di kolom itu, jadi query-nya jalan lebih lama di tabel besar. Kalau butuh performa, mending kolomnya dibikin NOT NULL dengan nilai default daripada dibersihin tiap kali query jalan.
Penutup
Tiga hal yang layak kamu inget. NULL artinya nggak diketahui, jadi perbandingan sama NULL selalu UNKNOWN. Fungsi agregasi ngelewatin NULL, dan itu bikin AVG kamu beda dari yang dibayangin. NOT IN dengan NULL di dalamnya selalu balikin nol baris.
Sebelum ngirim laporan berikutnya, jalanin SELECT COUNT(*) - COUNT(kolom) di tiap kolom yang kamu agregasi. Lima detik, dan kamu tau angkanya bisa dipercaya atau nggak.
Lanjut ke kenapa JOIN melipatgandakan baris buat jebakan JOIN yang satunya lagi.
Mau praktek langsung? Mulai latihan SQL gratis
Latihan interaktif, langsung di browser.
Artikel terkait
Cara Membuat Pivot Dinamis di SQL (Panduan 2026)
Pivot dinamis di SQL ngubah baris jadi kolom tanpa kamu hardcode nama kolomnya. Ini cara bikinnya pakai CASE WHEN dan versi yang kolomnya ngikut data.
Menghitung YTD, QTD, dan MTD di SQL (Panduan 2026)
YTD, QTD, dan MTD ngukur total dari awal tahun, kuartal, atau bulan sampai hari ini. Ini cara ngitungnya di SQL pakai DATE_TRUNC, plus satu query gabungan.
Gap and Island Analysis di SQL
Gap and island analysis di SQL nyari deret data yang berturut-turut (island) dan celah yang bolong (gap). Cocok buat hitung streak login atau cari tanggal transaksi yang hilang.