📘 SERIES · CHAPTER 73🗄️ SQL · DATA CLEANING

SQL Data Cleaning Techniques — Handling Nulls, Duplicates, Inconsistent Values & Quality Checks

Data analysts spend 50–80% of their time cleaning data before any analysis can begin. This chapter covers every SQL technique for identifying and fixing the most common data quality problems — from NULL handling and duplicate removal through text standardisation, date repairs, outlier detection, and building automated quality check queries.

⏱ 24 min read📅 September 2026✍ EVIKA ACADEMY, Noida

Why Clean Data in SQL Rather Than Excel?

SQL data cleaning has three advantages over Excel: it handles millions of rows instantly, it is reproducible (the same query runs every time new data arrives), and it leaves the original data untouched while producing a clean output. For any dataset over 100,000 rows or any cleaning that needs to repeat regularly, SQL is the right tool.

✅ Scale
SQL handles 100M rows in the same time Excel takes to scroll to row 1M.
✅ Reproducibility
Save the query. Run it again on next month's data with zero extra effort.
✅ Non-destructive
Original table is never modified. Clean output is a new table or view.
✅ Automatable
Schedule the cleaning query to run nightly. Dashboards always see clean data.

Handling NULL Values

NULL is not zero and not an empty string. It means "unknown." SQL treats NULL differently from all other values — any arithmetic or comparison with NULL returns NULL. Understanding this is essential before cleaning.

1. Find NULLs — Profiling Your Data First

-- Count NULLs in every column in one query
SELECT
    COUNT(*) AS total_rows,
    SUM(CASE WHEN customer_id   IS NULL THEN 1 ELSE 0 END) AS null_customer_id,
    SUM(CASE WHEN email         IS NULL THEN 1 ELSE 0 END) AS null_email,
    SUM(CASE WHEN order_date    IS NULL THEN 1 ELSE 0 END) AS null_order_date,
    SUM(CASE WHEN revenue       IS NULL THEN 1 ELSE 0 END) AS null_revenue,
    SUM(CASE WHEN city          IS NULL THEN 1 ELSE 0 END) AS null_city
FROM orders;

2. Replace NULLs — COALESCE and ISNULL / NVL

-- COALESCE returns the first non-NULL value from the list
-- Works in all major databases (MySQL, PostgreSQL, SQL Server, BigQuery)
SELECT
    order_id,
    COALESCE(discount_pct, 0)          AS discount_pct,   -- NULL → 0
    COALESCE(city, 'Unknown')           AS city,            -- NULL → 'Unknown'
    COALESCE(phone, email, 'No contact') AS contact         -- first non-NULL wins
FROM orders;

-- SQL Server shorthand (single-column only):
-- ISNULL(discount_pct, 0)

-- Oracle / older databases:
-- NVL(discount_pct, 0)

3. Filter Out NULLs — IS NULL vs = NULL

-- CORRECT — use IS NULL / IS NOT NULL
SELECT * FROM orders WHERE email IS NULL;
SELECT * FROM orders WHERE email IS NOT NULL;

-- WRONG — this returns 0 rows even when NULLs exist
-- SELECT * FROM orders WHERE email = NULL;   ← never use this

-- Exclude rows where ANY key column is NULL
SELECT *
FROM orders
WHERE customer_id IS NOT NULL
  AND order_date   IS NOT NULL
  AND revenue      IS NOT NULL;

4. NULL-Safe Aggregations

-- COUNT(*) counts rows including NULLs
-- COUNT(column) counts only non-NULL values in that column
SELECT
    COUNT(*)         AS total_rows,          -- includes rows with NULL revenue
    COUNT(revenue)   AS rows_with_revenue,   -- excludes NULL revenue rows
    AVG(revenue)     AS avg_revenue,         -- AVG ignores NULLs automatically
    SUM(revenue)     AS total_revenue        -- SUM ignores NULLs automatically
FROM orders;

-- To include NULLs in AVG (treat NULL as 0):
SELECT AVG(COALESCE(revenue, 0)) AS avg_revenue_incl_nulls
FROM orders;

Removing Duplicates

Duplicates inflate counts, distort averages, and ruin JOIN results. There are two types: exact row duplicates (every column identical) and key duplicates (same business entity, different values). Treat them differently.

1. Detect Duplicates

-- Find duplicate order_ids
SELECT order_id, COUNT(*) AS occurrences
FROM orders
GROUP BY order_id
HAVING COUNT(*) > 1
ORDER BY occurrences DESC;

-- Full exact-row duplicates
SELECT *, COUNT(*) AS occurrences
FROM orders
GROUP BY order_id, customer_id, order_date, revenue, city
HAVING COUNT(*) > 1;

2. Keep One Row per Key — ROW_NUMBER() Method

-- Keep the latest record per customer (most recent order_date wins)
WITH ranked AS (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY order_date DESC
           ) AS rn
    FROM orders
)
SELECT *
FROM ranked
WHERE rn = 1;

-- For exact duplicates — keep any one copy:
WITH deduped AS (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY order_id, customer_id, order_date
               ORDER BY (SELECT NULL)   -- arbitrary tiebreak
           ) AS rn
    FROM orders
)
SELECT * FROM deduped WHERE rn = 1;

3. Delete Duplicates (when you have write access)

-- SQL Server / PostgreSQL — delete keeping lowest ctid/rowid
WITH ranked AS (
    SELECT ctid,
           ROW_NUMBER() OVER (
               PARTITION BY order_id
               ORDER BY ctid
           ) AS rn
    FROM orders
)
DELETE FROM orders
WHERE ctid IN (SELECT ctid FROM ranked WHERE rn > 1);

-- MySQL — using self-join
DELETE t1
FROM orders t1
INNER JOIN orders t2
    ON t1.order_id = t2.order_id
    AND t1.id > t2.id;   -- keep the row with the lower auto-increment id

Standardising Text Data

"Mumbai", "mumbai", "MUMBAI", "Mumbai " (trailing space) will produce 4 separate groups in a GROUP BY. Text standardisation is one of the highest-impact cleaning steps for any dimension data (city, category, product name, status).

SELECT
    -- Trim whitespace
    TRIM(city)                         AS city_trimmed,

    -- Standardise case
    UPPER(TRIM(city))                  AS city_upper,
    LOWER(TRIM(city))                  AS city_lower,

    -- Title case (initcap — PostgreSQL / Oracle)
    INITCAP(LOWER(TRIM(city)))         AS city_title,

    -- Replace inconsistent values
    CASE
        WHEN LOWER(TRIM(city)) IN ('bangalore', 'bengaluru', 'blr')
             THEN 'Bengaluru'
        WHEN LOWER(TRIM(city)) IN ('mumbai', 'bombay')
             THEN 'Mumbai'
        WHEN LOWER(TRIM(city)) IN ('delhi', 'new delhi', 'ndls')
             THEN 'Delhi'
        ELSE INITCAP(LOWER(TRIM(city)))
    END                                AS city_clean,

    -- Remove special characters (PostgreSQL REGEXP_REPLACE)
    REGEXP_REPLACE(phone, '[^0-9]', '', 'g')  AS phone_digits_only,

    -- Extract domain from email
    SUBSTRING(email FROM POSITION('@' IN email) + 1)  AS email_domain

FROM customers;
Pro tip: Before using CASE for value standardisation, run a SELECT LOWER(TRIM(city)), COUNT(*) FROM table GROUP BY 1 ORDER BY 2 DESC to get the full list of variants. You will often find 15–20 variations of the same city name in real data.

Fixing Date and Timestamp Issues

-- Convert text stored as date string to a proper DATE type
-- PostgreSQL
SELECT TO_DATE(order_date_text, 'DD/MM/YYYY')  AS order_date
FROM orders;

-- SQL Server
SELECT CONVERT(DATE, order_date_text, 103)     AS order_date   -- 103 = DD/MM/YYYY
FROM orders;

-- MySQL
SELECT STR_TO_DATE(order_date_text, '%d/%m/%Y') AS order_date
FROM orders;

-- BigQuery
SELECT PARSE_DATE('%d/%m/%Y', order_date_text)  AS order_date
FROM orders;

-- ── Find impossible dates (future dates, dates before business started) ──
SELECT order_id, order_date
FROM orders
WHERE order_date > CURRENT_DATE             -- future dates
   OR order_date < '2015-01-01'            -- before company existed
   OR order_date IS NULL;                  -- missing dates

-- ── Fix dates where day and month may be swapped ──
-- (e.g. 07/06/2024 could be July 6 or June 7)
-- Profile first:
SELECT
    EXTRACT(MONTH FROM order_date) AS month_num,
    COUNT(*)
FROM orders
GROUP BY 1
ORDER BY 1;
-- If any month has suspiciously 0 or very low counts, day/month swap is likely.

Outlier Detection in SQL

Outliers in numeric data can be genuine (a ₹50L enterprise contract in a mostly ₹5K consumer dataset) or data errors (a negative price, a 999 age). Always investigate before removing.

-- Method 1: Simple boundary check (domain knowledge)
SELECT * FROM orders
WHERE revenue < 0          -- negative revenue is impossible
   OR revenue > 1000000    -- flag orders over ₹10L for review
   OR quantity = 0;        -- zero-quantity orders are likely errors

-- Method 2: IQR (Interquartile Range) method — statistical outliers
WITH stats AS (
    SELECT
        PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY revenue) AS q1,
        PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY revenue) AS q3
    FROM orders
),
bounds AS (
    SELECT
        q1 - 1.5 * (q3 - q1) AS lower_fence,
        q3 + 1.5 * (q3 - q1) AS upper_fence
    FROM stats
)
SELECT o.*
FROM orders o
CROSS JOIN bounds b
WHERE o.revenue < b.lower_fence
   OR o.revenue > b.upper_fence;

-- Method 3: Z-score (flag values > 3 standard deviations from mean)
WITH stats AS (
    SELECT AVG(revenue) AS mean_rev, STDDEV(revenue) AS std_rev
    FROM orders
)
SELECT o.*, ABS(o.revenue - s.mean_rev) / s.std_rev AS z_score
FROM orders o, stats s
WHERE ABS(o.revenue - s.mean_rev) / s.std_rev > 3
ORDER BY z_score DESC;

Building a Reusable Data Quality Check Query

Instead of running separate checks for each issue, build a single quality dashboard query that flags all problems at once. Run it every time new data arrives before starting any analysis.

-- ── DAILY DATA QUALITY REPORT ──
SELECT 'orders' AS table_name, 'total_rows'         AS check_name,
       COUNT(*) AS value, NULL AS flag
FROM orders

UNION ALL SELECT 'orders', 'null_customer_id',
       SUM(CASE WHEN customer_id IS NULL THEN 1 ELSE 0 END),
       CASE WHEN SUM(CASE WHEN customer_id IS NULL THEN 1 ELSE 0 END) > 0
            THEN '⚠️ FAIL' ELSE '✅ PASS' END
FROM orders

UNION ALL SELECT 'orders', 'duplicate_order_ids',
       COUNT(*) - COUNT(DISTINCT order_id),
       CASE WHEN COUNT(*) - COUNT(DISTINCT order_id) > 0
            THEN '⚠️ FAIL' ELSE '✅ PASS' END
FROM orders

UNION ALL SELECT 'orders', 'negative_revenue',
       SUM(CASE WHEN revenue < 0 THEN 1 ELSE 0 END),
       CASE WHEN SUM(CASE WHEN revenue < 0 THEN 1 ELSE 0 END) > 0
            THEN '⚠️ FAIL' ELSE '✅ PASS' END
FROM orders

UNION ALL SELECT 'orders', 'future_order_dates',
       SUM(CASE WHEN order_date > CURRENT_DATE THEN 1 ELSE 0 END),
       CASE WHEN SUM(CASE WHEN order_date > CURRENT_DATE THEN 1 ELSE 0 END) > 0
            THEN '⚠️ FAIL' ELSE '✅ PASS' END
FROM orders

ORDER BY flag DESC, check_name;
Save this as a scheduled query. Set it to run every morning and email results to the data team. Any ⚠️ FAIL rows need investigation before that day's reports are published.

The Clean Data CTE Pattern — Putting It All Together

Rather than cleaning data in multiple separate queries, use a multi-step CTE that builds a clean version of the table in one readable, maintainable query. This is the production pattern used in analytics engineering.

WITH
-- Step 1: Remove exact duplicates
deduped AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) AS rn
    FROM raw_orders
),

-- Step 2: Apply all cleaning transformations
cleaned AS (
    SELECT
        order_id,
        customer_id,
        TRIM(INITCAP(LOWER(city)))              AS city,
        COALESCE(discount_pct, 0)               AS discount_pct,
        CASE
            WHEN revenue < 0 THEN NULL           -- flag negatives as unknown
            ELSE revenue
        END                                      AS revenue,
        CASE
            WHEN order_date > CURRENT_DATE THEN NULL  -- remove future dates
            ELSE order_date
        END                                      AS order_date,
        LOWER(TRIM(status))                      AS status
    FROM deduped
    WHERE rn = 1                                 -- keep latest per order_id
      AND customer_id IS NOT NULL                -- drop orphaned records
),

-- Step 3: Filter to valid complete records
final AS (
    SELECT *
    FROM cleaned
    WHERE order_date IS NOT NULL
      AND revenue    IS NOT NULL
)

SELECT * FROM final;

Master SQL Data Cleaning at EVIKA ACADEMY

Our SQL course in Noida Sector 51 covers data cleaning from profiling through production CTE pipelines — with real messy datasets you clean from scratch. Online & offline classes available.

📱 Book Free Demo Class →

Frequently Asked Questions

How do you handle NULL values in SQL?

Use COALESCE(column, default_value) to replace NULLs — for example COALESCE(discount_pct, 0) replaces NULL discounts with zero. To find NULLs, use IS NULL (never = NULL). COUNT(column) excludes NULLs while COUNT(*) includes them. AVG and SUM automatically ignore NULLs.

How do you remove duplicates in SQL?

Use ROW_NUMBER() OVER (PARTITION BY key_column ORDER BY date DESC) AS rn in a CTE, then SELECT WHERE rn = 1. This keeps the latest record per key. For exact row duplicates, GROUP BY all columns with HAVING COUNT(*) > 1 to find them first.

What is the best way to standardise text data in SQL?

Combine TRIM() for whitespace, LOWER()/UPPER() for case, and a CASE statement for known variants. Always profile the raw values with GROUP BY first to find all the variants before writing the CASE statement.

How do you detect outliers in SQL?

Three methods: (1) Domain boundary checks — flag logically impossible values. (2) IQR method using PERCENTILE_CONT to find Q1/Q3 and flag values outside 1.5×IQR. (3) Z-score method flagging values where ABS(value - AVG) / STDDEV > 3. Always investigate before removing.

Where can I learn SQL data cleaning in Noida?

EVIKA ACADEMY in Noida Sector 51 covers NULL handling, duplicate removal, text standardisation, date repairs, outlier detection and production CTE pipelines in its SQL course. Online and offline classes available. WhatsApp +91-8081035456 to book a free demo.

🎓 Free Demo Class — Online & Offline · Noida Sector 51