Standardizing individual values is only part of the job — production data also accumulates duplicate rows, blank-but-not-NULL fields, and inconsistent representations of “no value.” This chapter covers detecting and resolving those problems at the row level.
Duplicate rows inflate counts, distort averages, and cause incorrect joins (a “one row per customer” assumption silently becomes “two or three rows per customer” after a bad import). Blank and whitespace-only values masquerade as “filled in” fields when checked only with IS NOT NULL, leading to false confidence in data completeness.
A customer signs up twice because a form was submitted twice due to a network retry. A nightly ETL job reruns after a partial failure and inserts the same day’s orders again. A required “company name” field technically isn’t NULL — it’s just a single space character, entered accidentally.
Correct duplicate handling prevents inflated revenue, customer count, and engagement metrics. Correct blank/NULL/whitespace detection prevents “100% complete” data quality reports that are actually hiding meaningfully empty fields.
Three states of "no meaningful value":
NULL → no value was ever stored
'' (empty) → a value was stored, and it's zero-length
' ' (whitespace) → a value was stored, and it's only spaces
IS NULL catches only the first
= '' catches only the second
TRIM(col) = '' catches the second AND third together
Duplicate detection pattern:
customer_id | order_date | amount <- these three columns
101 | 2026-01-05 | 49.99 together define
101 | 2026-01-05 | 49.99 "the same order"
102 | 2026-01-06 | 19.99 in this business
GROUP BY customer_id, order_date, amount
HAVING COUNT(*) > 1 --> flags the duplicate pair
-- Blank / whitespace-only detection
WHERE column IS NULL
WHERE column = ''
WHERE TRIM(column) = ''
-- Finding duplicates by natural key
SELECT key_col1, key_col2, COUNT(*)
FROM table_name
GROUP BY key_col1, key_col2
HAVING COUNT(*) > 1;
-- Removing duplicates, keeping the lowest id per group (MySQL 8+)
DELETE t1 FROM table_name t1
INNER JOIN table_name t2
ON t1.key_col = t2.key_col AND t1.id > t2.id;
-- Removing duplicates using ROW_NUMBER() (MySQL 8+/PostgreSQL)
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY key_col ORDER BY id) AS rn
FROM table_name
)
DELETE FROM table_name WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
Detecting duplicates requires first defining what “duplicate” means in business terms — an exact duplicate of every column is rare; more commonly, a natural key (customer + date + amount, or email address alone) defines uniqueness, and rows matching on that key but differing elsewhere (a timestamp, a row ID) are still duplicates for business purposes.
The GROUP BY ... HAVING COUNT(*) > 1 pattern identifies duplicate groups but doesn’t remove anything by itself — it’s a detection query, meant to run before any deletion, so a human or an automated process can confirm the duplicates are safe to remove. Removal itself typically uses ROW_NUMBER() to rank rows within each duplicate group (commonly by a timestamp or ID to decide which copy is “the original”) and deletes everything except rank 1.
Blank and whitespace-only values require TRIM(column) = '' specifically — checking column = '' alone misses whitespace-only entries, and IS NULL alone misses both.
GROUP BY ... HAVING COUNT(*) > 1) before any deletion — never delete blindROW_NUMBER() or a self-join, ideally inside a transaction with a rollback planDELETE statements as a SELECT first to preview exactly which rows would be removedGROUP BY across a large table for duplicate detection can be expensive without a supporting index on the natural key columns. For very large fact tables, consider partitioning the detection query by date range rather than scanning the entire table at once.
IS NULL or only = '' and missing whitespace-only valuesDELETE directly without first previewing the affected rows via SELECTGROUP BY-based duplicate detection, since most engines group all NULLs together, potentially treating unrelated NULL-keyed rows as “duplicates”SELECT before running the equivalent DELETEGROUP BY ... HAVING COUNT(*) > 1 considered a detection query rather than a cleanup query?Row-level data cleaning centers on two problems: rows that shouldn’t be counted twice, and fields that look “filled in” but aren’t meaningfully so. Duplicate detection requires an explicit, business-defined natural key and a clear “keep” rule before any deletion; blank detection requires checking NULL, empty string, and whitespace-only as three separate conditions.
customers table.customer_name is technically not NULL but is empty or whitespace-only.ROW_NUMBER(), write a query that identifies which row to keep and which to remove for each duplicate email group, keeping the lowest customer_id.