Data entered by people, imported from spreadsheets or collected from forms is almost never tidy. Names have extra spaces at the start, emails are typed in capitals, one city appears as "leeds", "Leeds " and "LEEDS", someone types "n/a" where a number should be, and the same person signs up twice. Every one of these breaks searches, counts and joins.
Analysts often say that most of their time goes into cleaning data before they can analyse it. SQL is an excellent tool for the job: you can inspect the problems with SELECT, fix thousands of rows with a single UPDATE, and remove duplicates with one DELETE.
In this lesson you'll work through a messy signups table, standardising text, finding and removing duplicates, converting blanks and invalid values, and following a safe routine that means you never damage good data while fixing bad data.
The messy signups table
| id | name | city | age | |
|---|---|---|---|---|
| 1 | ana silva | Ana@Example.com | lisbon | 34 |
| 2 | Ben Hart | ben@example.com | Leeds | 29 |
| 3 | ana silva | ana@example.com | Lisbon | 34 |
| 4 | Chloe Roux | chloe@example | LYON | (blank) |
| 5 | Dev Shah | dev@example.com | leeds | n/a |
Spot the problems: spaces before and after values, inconsistent capitals, a duplicate person (rows 1 and 3), an email with no domain ending, a blank age and an age of "n/a". Each needs a different fix.
Standardising text
| Function | Does | Example |
|---|---|---|
TRIM(x) | Removes spaces from both ends | ' ana ' to 'ana' |
LOWER(x) / UPPER(x) | Changes case | 'Ana@X.com' to 'ana@x.com' |
REPLACE(x, a, b) | Replaces text | REPLACE(phone, ' ', '') |
SUBSTR(x, start, len) | Takes part of the text | SUBSTR('lyon', 1, 1) is 'l' |
Combine them for the result you want. Emails are usually stored in lowercase, because Ana@Example.com and ana@example.com are the same address. For a city, a capital first letter and lowercase for the rest gives Lyon. PostgreSQL has INITCAP() for this; in SQLite and MySQL you build it from SUBSTR, UPPER and LOWER.
Finding and removing duplicates
Duplicates are found by grouping on whatever makes a record unique, here the cleaned email, and keeping groups with more than one row:
SELECT LOWER(TRIM(email)) AS email, COUNT(*) AS copies
FROM signups
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1;To delete the extra copies, keep the row with the lowest id in each group and delete the others. In databases that support it, ROW_NUMBER() from the window functions lesson gives even more control, such as keeping the most recent copy instead.
Blanks, numbers and invalid values
NULLIF(TRIM(x), '')turns an empty string into NULL, so missing values are treated consistently.CAST(x AS INTEGER)converts text to a number. Be careful: SQLite turns'n/a'into0and MySQL gives a warning, while PostgreSQL stops with an error. Check values look numeric first.LIKEfinds values with the wrong shape.email NOT LIKE '%_@_%._%'finds emails without something@something.something.
After cleaning, prevent the problem coming back with constraints such as UNIQUE on email and CHECK (age BETWEEN 13 AND 120).
SQL Data Cleaning Syntax
Cleaning patterns
TRIM(col) LOWER(col) UPPER(col) REPLACE(col, 'a', 'b')
NULLIF(TRIM(col), '')
CAST(col AS INTEGER)
SELECT key, COUNT(*) FROM t GROUP BY key HAVING COUNT(*) > 1;
DELETE FROM t
WHERE id NOT IN (SELECT MIN(id) FROM t GROUP BY key);SQL Data Cleaning Summary
- TRIM, LOWER, UPPER, REPLACE and SUBSTR standardise text.
- Find duplicates with GROUP BY on the cleaned key and HAVING COUNT(*) > 1.
- Delete duplicates by keeping MIN(id) in each group.
- NULLIF turns blanks into NULL; check text looks numeric before CAST.
- Preview fixes with SELECT, then UPDATE inside a transaction, and add constraints afterwards.
SQL Data Cleaning Examples
Each example shows the code, the output it produces and what each part does.
Previewing the clean values
SELECT id,
TRIM(name) AS name,
LOWER(TRIM(email)) AS email,
UPPER(SUBSTR(TRIM(city), 1, 1)) ||
LOWER(SUBSTR(TRIM(city), 2)) AS city
FROM signups;id | name | email | city
---+------------+-----------------+-------
1 | ana silva | ana@example.com | Lisbon
2 | Ben Hart | ben@example.com | Leeds
3 | ana silva | ana@example.com | Lisbon
4 | Chloe Roux | chloe@example | Lyon
5 | Dev Shah | dev@example.com | LeedsLine-by-line explanation
- Line 2TRIM removes the spaces in front of row 1's name.
- Line 3Emails are trimmed and lowercased, so rows 1 and 3 now match exactly.
- Lines 4 to 5First letter upper case, the rest lower case. In MySQL, use CONCAT instead of ||.
Finding problem values
SELECT id, email
FROM signups
WHERE email NOT LIKE '%_@_%._%';
SELECT id,
age,
CASE WHEN TRIM(age) <> '' AND TRIM(age) NOT GLOB '*[^0-9]*'
THEN CAST(age AS INTEGER)
END AS age_number
FROM signups;id | email
---+--------------
4 | chloe@example
id | age | age_number
---+-----+-----------
1 | 34 | 34
2 | 29 | 29
3 | 34 | 34
4 | | NULL
5 | n/a | NULLLine-by-line explanation
- Line 3The pattern needs at least one character, an @, more characters, a dot and more characters.
- Lines 7 to 8Convert only when the age is not blank and contains nothing but digits. GLOB is SQLite; MySQL uses age REGEXP '^[0-9]+$' and PostgreSQL age ~ '^[0-9]+$'.
- Line 9A CASE with no ELSE gives NULL, so blanks and "n/a" become NULL instead of a wrong 0.
Fixing the table for good
UPDATE signups
SET name = TRIM(name),
email = LOWER(TRIM(email)),
city = UPPER(SUBSTR(TRIM(city), 1, 1)) || LOWER(SUBSTR(TRIM(city), 2));
DELETE FROM signups
WHERE id NOT IN (SELECT MIN(id) FROM signups GROUP BY email);
SELECT id, name, email, city FROM signups;id | name | email | city
---+------------+-----------------+-------
1 | ana silva | ana@example.com | Lisbon
2 | Ben Hart | ben@example.com | Leeds
4 | Chloe Roux | chloe@example | Lyon
5 | Dev Shah | dev@example.com | LeedsLine-by-line explanation
- Lines 1 to 4The same expressions as the preview, now saved into the table.
- Lines 6 to 7For each email, keep the row with the smallest id and delete the rest. Row 3 is removed.
- Line 9Check the result. Chloe's email still needs a human to fix it.
Common SQL Data Cleaning Mistakes
Updating without previewing
UPDATE signups SET city = UPPER(city); -- straight into the real dataSELECT city, UPPER(city) AS new_city FROM signups; -- check first
-- then run the UPDATE inside a transactionFinding duplicates on raw values
SELECT email, COUNT(*) FROM signups GROUP BY email HAVING COUNT(*) > 1;
-- misses 'Ana@Example.com' and 'ana@example.com'SELECT LOWER(TRIM(email)), COUNT(*) FROM signups
GROUP BY LOWER(TRIM(email)) HAVING COUNT(*) > 1;Casting text blindly
SELECT AVG(CAST(age AS INTEGER)) FROM signups; -- 'n/a' and '' count as 0SELECT AVG(CAST(NULLIF(TRIM(age), '') AS INTEGER))
FROM signups
WHERE TRIM(age) NOT GLOB '*[^0-9]*';SQL Data Cleaning in Real Projects
Data cleaning at work
Before a mailing goes out, marketing teams deduplicate contact lists so nobody gets the same email twice. Analysts standardise city and country names before building maps, and finance teams strip currency symbols and spaces from imported numbers. Data pipelines often run a cleaning step in SQL every night, loading raw data into one table and writing the cleaned version into another.
SQL Data Cleaning FAQ
Should I clean data in SQL or in a spreadsheet or Python?
Clean it where it lives when you can. SQL updates are repeatable, can be reviewed and work on millions of rows without copying data around. Spreadsheets suit a one-off look at a small file, and Python or similar tools help with complex parsing; even then, write the cleaned result back with SQL and keep the steps in a script.
How do I standardise phone numbers or codes?
Remove the characters that vary, then compare. Chained REPLACE calls work everywhere: REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''). PostgreSQL's regexp_replace(phone, '[^0-9+]', '', 'g') keeps only digits and the plus sign in one step. Store the cleaned value in a consistent format.
How do I find text values that aren't valid numbers?
In PostgreSQL, use a pattern: WHERE amount_text !~ '^[0-9]+(\.[0-9]+)?$'. In SQLite, WHERE amount_text GLOB '*[^0-9.]*' finds values containing anything other than digits and full stops. Review these rows before converting the column with CAST.
How do I stop messy data coming back?
Add constraints once the data is clean: NOT NULL for required columns, UNIQUE on values such as email addresses, CHECK rules for ranges and allowed values, and foreign keys for references. Validate input in the application too, so users get friendly messages before the database rejects anything.
Should I keep the original values before cleaning?
Yes. Copy the raw table first, CREATE TABLE signups_raw AS SELECT * FROM signups;, or add cleaned values in new columns rather than overwriting. If a cleaning rule turns out to be wrong, you can rerun it from the original data instead of guessing what was lost.
SQL Data Cleaning Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the query to remove spaces from both ends of each name.
SELECT (name) FROM signups;
Which clause keeps only groups that appear more than once?
What does SELECT NULLIF(TRIM(' '), ''); return?
Why lowercase emails before looking for duplicates?
Which DELETE keeps one copy of each duplicated email?
SQL Data Cleaning Quiz
5 questions to check what you learned.
What does
LOWER('LYON')return?- LYON
- lyon
- Lyon
- l
What's the safest first step before an UPDATE that cleans data?
- Drop the table
- Run the same expressions in a SELECT and check the results
- Turn off constraints
- Delete duplicates
In SQLite, what does
CAST('n/a' AS INTEGER)return?- 0
- NULL
- An error
- 'n/a'
After cleaning a column of emails, how do you stop duplicates returning?
- Run the cleanup every day
- Use UNION
- Add an index on id
- Add a UNIQUE constraint on the email column
Which pattern finds emails missing a dot after the @?
- email LIKE '%@%'
- email = '%.%'
- email NOT LIKE '%_@_%._%'
- email NOT LIKE '.'