TL;DR
Data cleaning itu 80% waktu kerja Data Analyst. 10 teknik yang wajib dikuasai: handling NULL (COALESCE, NULLIF), hapus duplikat (DISTINCT, ROW_NUMBER), standardisasi text (TRIM, UPPER), fix typos, validasi format, handle outliers, type conversion, date fixing, merge data, dan bikin clean dimension tables.
"Garbage in, garbage out."
Pernah denger istilah itu kan? Mau sehebat apapun model atau dashboard kamu, kalau datanya kotor, hasilnya ya sampah juga.
Faktanya, Data Analyst ngabisin sekitar 80% waktunya buat data cleaning dan preparation. Cuma 20% buat analisis yang "keren-keren". Agak nyebelin sih, tapi ini realita.
Data kotor itu bisa berbentuk macam-macam: NULL values yang ga jelas, duplikat data, typo, format yang ga konsisten, outliers aneh, dan masih banyak lagi. Kalau ga di-handle dengan bener, analisis kamu bisa salah total.
Kamu bakal belajar 10 teknik data cleaning yang wajib dikuasai. Semua pakai SQL, dan contohnya pakai dataset Indonesia biar lebih relate.
Kamu dapet dataset hasil survey pelanggan. Datanya berantakan banget karena input manual.
| id | nama | kota | no_hp | tanggal_lahir | pendapatan | tanggal_survey | |
|---|---|---|---|---|---|---|---|
| 1 | Budi Santoso | budi@email.com | Jakarta | 081234567890 | 1990-05-15 | 15000000 | 2024-01-15 |
| 2 | SITI RAHAYU | siti@email.com | jakarta | 08-2345-6789 | 15/08/1995 | NULL | 2024-01-15 |
| 3 | andi wijaya | NULL | Bandung | 6283456789012 | 1988-12-03 | 25000000 | 2024-01-16 |
| 4 | Budi Santoso | budi@email.com | Jakarta | 081234567890 | 1990-05-15 | 15000000 | 2024-01-15 |
| 5 | Dewi Lestari | dewi@gmail.com | Jakrta | 0811-234-5678 | 03-02-1992 | 8500000 | 2024-01-16 |
| 6 | Maya Putri | maya@email | Bandung | 812345678 | 1997/07/20 | 50000000000 | 2024-01-17 |
| 7 | Rudi Hartono | rudi@email.com | Surabaya | +62 856 1234 5678 | NULL | 12000000 | 2024-01-17 |
| 8 | NULL | unknown@test.com | Medan | 081234567890 | 1985-11-11 | -5000000 | 2024-01-18 |
| 9 | Agus Pratama | agus@email.com | bandung | tidak punya hp | 2024-06-15 | 9000000 | 2024-01-18 |
| 10 | Linda Sari | linda@email.com | SEMARANG | 085678901234 | 1993-04-25 | 0 | 2024-01-19 |
Liat kan betapa berantakannya? Ada nama yang uppercase, lowercase, mixed. Kota yang typo. Format HP yang ga konsisten. Tanggal lahir yang formatnya beda-beda. Pendapatan yang aneh. Kita beresin satu per satu.
NULL itu bukan 0, bukan empty string, tapi "nggak ada nilai". Cara handle-nya tergantung konteks.
SELECT
id,
COALESCE(nama, 'Tidak Diketahui') AS nama,
COALESCE(email, 'tidak.ada@email.com') AS email,
COALESCE(pendapatan, 0) AS pendapatan
FROM survey_raw;
COALESCE bakal return nilai pertama yang bukan NULL. Bisa juga chain multiple values:
SELECT
id,
COALESCE(nama, email, 'Anonymous') AS identifier
FROM survey_raw;
Kadang ada nilai yang harusnya NULL tapi diisi dengan placeholder kayak 0, -1, atau 'N/A'.
SELECT
id,
nama,
NULLIF(pendapatan, 0) AS pendapatan, -- 0 jadi NULL
NULLIF(no_hp, 'tidak punya hp') AS no_hp -- placeholder jadi NULL
FROM survey_raw;
Sebelum cleaning, selalu cek dulu seberapa banyak NULL di setiap kolom:
SELECT
COUNT(*) AS total_rows,
COUNT(*) - COUNT(nama) AS null_nama,
COUNT(*) - COUNT(email) AS null_email,
COUNT(*) - COUNT(kota) AS null_kota,
COUNT(*) - COUNT(no_hp) AS null_no_hp,
COUNT(*) - COUNT(tanggal_lahir) AS null_tgl_lahir,
COUNT(*) - COUNT(pendapatan) AS null_pendapatan
FROM survey_raw;
Hasil:
| total_rows | null_nama | null_email | null_kota | null_no_hp | null_tgl_lahir | null_pendapatan |
|---|---|---|---|---|---|---|
| 10 | 1 | 1 | 0 | 0 | 1 | 1 |
Pertama, cari dulu row yang duplikat:
SELECT
nama,
email,
COUNT(*) AS jumlah
FROM survey_raw
GROUP BY nama, email
HAVING COUNT(*) > 1;
Hasil:
| nama | jumlah | |
|---|---|---|
| Budi Santoso | budi@email.com | 2 |
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY nama, email
ORDER BY tanggal_survey DESC
) AS rn
FROM survey_raw
)
SELECT *
FROM ranked
WHERE rn = 1;
Query ini keep row terbaru (tanggal_survey paling recent) dan buang sisanya.
Kalau mau simple dan semua kolom sama persis:
SELECT DISTINCT *
FROM survey_raw;
Tapi hati-hati, DISTINCT ngebandingin SEMUA kolom. Kalau ada satu kolom beda (misal id), ga bakal dianggap duplikat.
SELECT
id,
TRIM(kota) AS kota_clean
FROM survey_raw;
Contoh: ' Surabaya ' jadi 'Surabaya'
SELECT
id,
UPPER(nama) AS nama_upper,
LOWER(email) AS email_lower,
INITCAP(nama) AS nama_proper -- PostgreSQL: capitalize each word
FROM survey_raw;
SELECT
id,
INITCAP(TRIM(LOWER(kota))) AS kota_standard
FROM survey_raw;
Hasil transformasi:
- 'jakarta' → 'Jakarta'
- 'SEMARANG' → 'Semarang'
- ' Surabaya ' → 'Surabaya'
Untuk typo yang udah diketahui, pake CASE WHEN:
SELECT
id,
CASE TRIM(LOWER(kota))
WHEN 'jakrta' THEN 'Jakarta'
WHEN 'jkt' THEN 'Jakarta'
WHEN 'bandung' THEN 'Bandung'
WHEN 'bdg' THEN 'Bandung'
WHEN 'sby' THEN 'Surabaya'
WHEN 'surabaya' THEN 'Surabaya'
WHEN 'semarang' THEN 'Semarang'
WHEN 'smg' THEN 'Semarang'
WHEN 'medan' THEN 'Medan'
ELSE INITCAP(TRIM(kota))
END AS kota_clean
FROM survey_raw;
Untuk production, lebih baik bikin lookup table:
-- Tabel mapping
CREATE TABLE kota_mapping (
kota_dirty VARCHAR(100),
kota_clean VARCHAR(100)
);
INSERT INTO kota_mapping VALUES
('jakrta', 'Jakarta'),
('jkt', 'Jakarta'),
('jakarta', 'Jakarta'),
('bdg', 'Bandung'),
('bandung', 'Bandung'),
('sby', 'Surabaya'),
('surabaya', 'Surabaya');
-- Pakai JOIN untuk cleaning
SELECT
s.id,
COALESCE(m.kota_clean, INITCAP(TRIM(s.kota))) AS kota_clean
FROM survey_raw s
LEFT JOIN kota_mapping m ON LOWER(TRIM(s.kota)) = m.kota_dirty;
SELECT
id,
email,
CASE
WHEN email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN 'Valid'
ELSE 'Invalid'
END AS email_status
FROM survey_raw;
Hasil:
| id | email_status | |
|---|---|---|
| 1 | budi@email.com | Valid |
| 6 | maya@email | Invalid |
SELECT
id,
no_hp,
CASE
WHEN no_hp ~ '^(\\+62|62|0)[0-9]{9,12}$' THEN 'Valid'
WHEN no_hp ~ '^[0-9\\-\\s\\+]+$' THEN 'Needs Formatting'
ELSE 'Invalid'
END AS hp_status
FROM survey_raw;
SELECT
id,
no_hp AS original,
-- Remove semua karakter non-digit
REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g') AS digits_only,
-- Format ke standard Indonesia (08xxx)
CASE
WHEN REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g') ~ '^62' THEN
'0' || SUBSTRING(REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g') FROM 3)
WHEN REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g') ~ '^8' THEN
'0' || REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g')
ELSE
REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g')
END AS hp_standard
FROM survey_raw
WHERE no_hp IS NOT NULL
AND no_hp !~ '[a-zA-Z]'; -- Exclude yang ada huruf
Hasil:
| id | original | digits_only | hp_standard |
|---|---|---|---|
| 1 | 081234567890 | 081234567890 | 081234567890 |
| 2 | 08-2345-6789 | 0823456789 | 0823456789 |
| 3 | 6283456789012 | 6283456789012 | 083456789012 |
| 7 | +62 856 1234 5678 | 6285612345678 | 085612345678 |
WITH stats AS (
SELECT
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY pendapatan) AS q1,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY pendapatan) AS q3
FROM survey_raw
WHERE pendapatan IS NOT NULL AND pendapatan > 0
)
SELECT
s.id,
s.nama,
s.pendapatan,
CASE
WHEN s.pendapatan < (st.q1 - 1.5 * (st.q3 - st.q1)) THEN 'Low Outlier'
WHEN s.pendapatan > (st.q3 + 1.5 * (st.q3 - st.q1)) THEN 'High Outlier'
ELSE 'Normal'
END AS outlier_status
FROM survey_raw s
CROSS JOIN stats st
WHERE s.pendapatan IS NOT NULL;
WITH bounds AS (
SELECT
PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY pendapatan) AS lower_bound,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY pendapatan) AS upper_bound
FROM survey_raw
WHERE pendapatan IS NOT NULL AND pendapatan > 0
)
SELECT
id,
nama,
pendapatan AS original,
GREATEST(LEAST(pendapatan, b.upper_bound), b.lower_bound) AS capped_pendapatan
FROM survey_raw s
CROSS JOIN bounds b
WHERE pendapatan IS NOT NULL;
SELECT *
FROM survey_raw
WHERE pendapatan > 0 -- Ga mungkin negatif
AND pendapatan < 1000000000 -- Max 1 Milyar (reasonable)
AND (tanggal_lahir IS NULL OR tanggal_lahir < CURRENT_DATE) -- Ga mungkin lahir di masa depan
AND (tanggal_lahir IS NULL OR tanggal_lahir > '1920-01-01'); -- Ga mungkin > 100 tahun
SELECT
id,
CAST(pendapatan AS NUMERIC) AS pendapatan_numeric,
pendapatan::TEXT AS pendapatan_text,
pendapatan::DECIMAL(15,2) AS pendapatan_decimal
FROM survey_raw;
-- PostgreSQL approach: handle error dengan CASE
SELECT
id,
no_hp,
CASE
WHEN no_hp ~ '^[0-9]+$' THEN no_hp::BIGINT
ELSE NULL
END AS no_hp_numeric
FROM survey_raw;
SELECT
id,
tanggal_lahir AS original,
CASE
-- Format: YYYY-MM-DD
WHEN tanggal_lahir ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN
tanggal_lahir::DATE
-- Format: DD/MM/YYYY
WHEN tanggal_lahir ~ '^[0-9]{2}/[0-9]{2}/[0-9]{4}$' THEN
TO_DATE(tanggal_lahir, 'DD/MM/YYYY')
-- Format: DD-MM-YYYY
WHEN tanggal_lahir ~ '^[0-9]{2}-[0-9]{2}-[0-9]{4}$' THEN
TO_DATE(tanggal_lahir, 'DD-MM-YYYY')
-- Format: YYYY/MM/DD
WHEN tanggal_lahir ~ '^[0-9]{4}/[0-9]{2}/[0-9]{2}$' THEN
TO_DATE(tanggal_lahir, 'YYYY/MM/DD')
ELSE NULL
END AS tanggal_lahir_clean
FROM survey_raw;
SELECT
tanggal_lahir,
CASE
WHEN tanggal_lahir ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}' THEN 'ISO (YYYY-MM-DD)'
WHEN tanggal_lahir ~ '^[0-9]{2}/[0-9]{2}/[0-9]{4}' THEN 'DD/MM/YYYY'
WHEN tanggal_lahir ~ '^[0-9]{2}-[0-9]{2}-[0-9]{4}' THEN 'DD-MM-YYYY'
WHEN tanggal_lahir ~ '^[0-9]{4}/[0-9]{2}/[0-9]{2}' THEN 'YYYY/MM/DD'
ELSE 'Unknown'
END AS format_detected,
COUNT(*) AS count
FROM survey_raw
WHERE tanggal_lahir IS NOT NULL
GROUP BY tanggal_lahir,
CASE
WHEN tanggal_lahir ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}' THEN 'ISO (YYYY-MM-DD)'
WHEN tanggal_lahir ~ '^[0-9]{2}/[0-9]{2}/[0-9]{4}' THEN 'DD/MM/YYYY'
WHEN tanggal_lahir ~ '^[0-9]{2}-[0-9]{2}-[0-9]{4}' THEN 'DD-MM-YYYY'
WHEN tanggal_lahir ~ '^[0-9]{4}/[0-9]{2}/[0-9]{2}' THEN 'YYYY/MM/DD'
ELSE 'Unknown'
END;
WITH parsed_dates AS (
SELECT
id,
nama,
tanggal_lahir AS original,
-- Parse ke DATE (simplified)
CASE
WHEN tanggal_lahir ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
THEN tanggal_lahir::DATE
ELSE NULL
END AS parsed_date
FROM survey_raw
)
SELECT
*,
CASE
WHEN parsed_date IS NULL THEN 'Parse Error'
WHEN parsed_date > CURRENT_DATE THEN 'Future Date (Invalid)'
WHEN parsed_date < '1920-01-01' THEN 'Too Old (Invalid)'
WHEN EXTRACT(YEAR FROM AGE(parsed_date)) < 18 THEN 'Under 18'
ELSE 'Valid'
END AS validation_status
FROM parsed_dates;
Kadang data yang harusnya satu record malah ke-split jadi beberapa row.
-- Tabel dengan multiple rows per customer
CREATE TABLE customer_phones (
customer_id INT,
phone_number VARCHAR(20)
);
INSERT INTO customer_phones VALUES
(1, '081234567890'),
(1, '082345678901'),
(2, '083456789012');
-- Merge jadi satu row per customer
SELECT
customer_id,
STRING_AGG(phone_number, ', ') AS all_phones
FROM customer_phones
GROUP BY customer_id;
Hasil:
| customer_id | all_phones |
|---|---|
| 1 | 081234567890, 082345678901 |
| 2 | 083456789012 |
-- Ambil nilai non-NULL pertama dari multiple sources
WITH customer_data AS (
SELECT 1 AS id, 'Budi' AS nama_sistem_a, NULL AS nama_sistem_b UNION ALL
SELECT 2, NULL, 'Siti' UNION ALL
SELECT 3, 'Andi', 'Andi Wijaya'
)
SELECT
id,
COALESCE(nama_sistem_b, nama_sistem_a) AS nama_final -- Prioritas B > A
FROM customer_data;
Best practice: bikin tabel terpisah untuk data yang udah di-clean.
CREATE TABLE dim_customer AS
WITH cleaned AS (
SELECT
id,
-- Clean nama
INITCAP(TRIM(COALESCE(nama, 'Unknown'))) AS nama,
-- Clean email
LOWER(TRIM(email)) AS email,
-- Clean kota
CASE TRIM(LOWER(kota))
WHEN 'jakrta' THEN 'Jakarta'
WHEN 'jkt' THEN 'Jakarta'
ELSE INITCAP(TRIM(kota))
END AS kota,
-- Clean no_hp
CASE
WHEN no_hp ~ '[a-zA-Z]' THEN NULL
ELSE REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g')
END AS no_hp,
-- Clean pendapatan
CASE
WHEN pendapatan IS NULL THEN NULL
WHEN pendapatan <= 0 THEN NULL
WHEN pendapatan > 500000000 THEN NULL -- Cap at 500jt
ELSE pendapatan
END AS pendapatan,
tanggal_survey,
-- Dedupe
ROW_NUMBER() OVER (PARTITION BY LOWER(email) ORDER BY tanggal_survey DESC) AS rn
FROM survey_raw
WHERE email IS NOT NULL -- Email sebagai unique identifier
)
SELECT
id AS customer_id,
nama,
email,
kota,
no_hp,
pendapatan,
tanggal_survey AS last_survey_date
FROM cleaned
WHERE rn = 1; -- Ambil yang terbaru per email
Setelah cleaning, selalu bikin report untuk validasi:
SELECT
'Total Records' AS metric,
COUNT(*)::TEXT AS value
FROM dim_customer
UNION ALL
SELECT
'Unique Emails',
COUNT(DISTINCT email)::TEXT
FROM dim_customer
UNION ALL
SELECT
'Records with NULL kota',
COUNT(*)::TEXT
FROM dim_customer
WHERE kota IS NULL
UNION ALL
SELECT
'Records with valid HP',
COUNT(*)::TEXT
FROM dim_customer
WHERE no_hp IS NOT NULL AND LENGTH(no_hp) >= 10
UNION ALL
SELECT
'Average Pendapatan',
ROUND(AVG(pendapatan))::TEXT
FROM dim_customer
WHERE pendapatan IS NOT NULL;
-- KURANG BAGUS (cleaning logic di setiap query)
SELECT
INITCAP(TRIM(nama)) AS nama,
CASE LOWER(kota) WHEN 'jakrta' THEN 'Jakarta' ELSE kota END AS kota
FROM survey_raw
WHERE ...;
-- LEBIH BAGUS (bikin tabel clean sekali, pakai berkali-kali)
CREATE TABLE survey_clean AS ...;
SELECT nama, kota
FROM survey_clean
WHERE ...;
-- POTENSI ERROR
SELECT TRIM(LOWER(nama)) -- Gimana kalau nama NULL?
FROM survey_raw;
-- LEBIH AMAN
SELECT TRIM(LOWER(COALESCE(nama, '')))
FROM survey_raw;
Jangan pernah UPDATE langsung ke tabel original. Selalu bikin tabel baru atau backup dulu.
-- JANGAN GINI
UPDATE survey_raw SET kota = 'Jakarta' WHERE kota = 'jakrta';
-- LEBIH BAIK
CREATE TABLE survey_clean AS SELECT ... FROM survey_raw;
Sebelum data siap dianalisis, pastikan:
Soal: Dari dataset survey_raw di atas, bikin query untuk:
1. Identifikasi semua masalah data quality (NULL, duplikat, format ga konsisten)
2. Bikin versi clean dari data tersebut
-- Part 1: Data Quality Report
SELECT 'Total Rows' AS issue, COUNT(*)::TEXT AS count FROM survey_raw
UNION ALL
SELECT 'Duplicate Emails', COUNT(*)::TEXT FROM (
SELECT email FROM survey_raw GROUP BY email HAVING COUNT(*) > 1
) sub
UNION ALL
SELECT 'NULL Nama', COUNT(*)::TEXT FROM survey_raw WHERE nama IS NULL
UNION ALL
SELECT 'NULL Email', COUNT(*)::TEXT FROM survey_raw WHERE email IS NULL
UNION ALL
SELECT 'Invalid Email Format', COUNT(*)::TEXT FROM survey_raw
WHERE email IS NOT NULL AND email NOT LIKE '%@%.%'
UNION ALL
SELECT 'Inconsistent Kota Case', COUNT(DISTINCT kota)::TEXT FROM survey_raw
UNION ALL
SELECT 'Suspicious Pendapatan', COUNT(*)::TEXT FROM survey_raw
WHERE pendapatan IS NOT NULL AND (pendapatan <= 0 OR pendapatan > 100000000);
-- Part 2: Clean Data
WITH cleaned AS (
SELECT
id,
INITCAP(TRIM(COALESCE(nama, 'Unknown'))) AS nama,
LOWER(TRIM(email)) AS email,
INITCAP(TRIM(
CASE LOWER(TRIM(kota))
WHEN 'jakrta' THEN 'Jakarta'
ELSE kota
END
)) AS kota,
REGEXP_REPLACE(no_hp, '[^0-9]', '', 'g') AS no_hp,
CASE
WHEN pendapatan <= 0 OR pendapatan > 100000000 THEN NULL
ELSE pendapatan
END AS pendapatan,
tanggal_survey,
ROW_NUMBER() OVER (PARTITION BY LOWER(email) ORDER BY tanggal_survey DESC) AS rn
FROM survey_raw
WHERE email IS NOT NULL
AND email LIKE '%@%.%'
)
SELECT id, nama, email, kota, no_hp, pendapatan, tanggal_survey
FROM cleaned
WHERE rn = 1;
Tergantung ukuran datanya. Di bawah 10.000 baris, Excel masih nyaman kok. Lebih dari itu, Excel mulai lemot dan rawan salah klik. SQL bisa handle jutaan baris, dan cleaning-nya berbentuk query. Bisa kamu jalanin ulang kapan aja tanpa ngulang kerja manual. Kalau datamu udah ada di database, langsung aja pakai SQL.
Jangan buru-buru. NULL di satu kolom nggak berarti seluruh row-nya sampah. Contohnya row dengan pendapatan NULL. Nama, kota, dan no HP-nya masih kepake buat analisis lain. Hapus row cuma kalau kolom kuncinya kosong, misalnya email yang jadi unique identifier. Sisanya handle pakai COALESCE atau biarin NULL.
Cek data quality dulu. Hitung NULL, duplikat, dan format aneh per kolom. Habis itu baru cleaning: standardisasi text, fix typo, konversi tipe data, dan dedup paling akhir. Dedup duluan itu bahaya lho, soalnya "Jakarta" dan "jakrta" bakal dianggap dua nilai beda. Terakhir, simpan hasilnya ke tabel clean yang terpisah.
Sebagian besar jalan. COALESCE, NULLIF, TRIM, dan CASE WHEN ada di semua database. Yang beda: operator regex ~ itu punya PostgreSQL, di MySQL ganti pakai REGEXP. INITCAP juga nggak ada di MySQL, kamu perlu kombinasi UPPER dan SUBSTRING sendiri. STRING_AGG di MySQL namanya GROUP_CONCAT.
Data cleaning emang ga se-glamor bikin dashboard atau model ML, tapi ini skill yang bikin kamu valuable sebagai Data Analyst. Inget poin-poin ini:
Data bersih = analisis akurat = keputusan bisnis yang lebih baik. Worth the effort!
Happy cleaning!
Kalau kamu udah paham data cleaning, next step-nya:
- EDA dengan SQL - eksplorasi data yang udah clean
- Cohort Analysis - analisis retention
- Window Functions - analisis lebih advanced
Mau praktek langsung? Mulai latihan SQL gratis
Latihan interaktif, langsung di browser.
Analisa per kuartal, hari kerja, atau musim jadi ribet kalau tiap query ngitung ulang atribut tanggal. Tabel kalender nyimpen semua atribut itu sekali, biar tinggal di-JOIN.
Laporan penjualan harian sering bolong di hari tanpa transaksi. Ini cara bikin deret tanggal lengkap di SQL biar tiap hari muncul, walau nilainya nol.
Struktur organisasi tersimpan sebagai kolom id_atasan yang saling nunjuk. Ini cara narik rantai jabatan, hitung total bawahan, dan span of control cuma pakai SQL.