Data Cleaning with SQL
From Raw Tables to Business Insights · PostgreSQL
Choosing the right cleaning approach in PostgreSQL: Query-Based SQL, Set-Based ETL & Staging, and Procedural PL/pgSQL. Tap any step to see the syntax and what it does.
1SOURCE
RAW DATA
Loaded into PostgreSQL from multiple sources
Bulk-loaded with COPY or \copy, the fastest way to get flat files into Postgres.
Excel or Google Sheets saved as CSV, then imported into a staging table.
Pulled in live via foreign data wrappers (postgres_fdw) or an ETL tool.
JSON responses landed in jsonb columns, then flattened for cleaning.
2ASSESS
Data Quality Assessment
Profile every issue before choosing a cleaning path
NULLs and empty strings that break joins, filters and aggregates.
Exact or near-duplicate rows that inflate counts and metrics.
Numbers or dates stored as text, which block math, sorting and indexes.
Mixed case, stray whitespace and encoding that stop values from matching.
Rows that violate constraints or reference a parent that no longer exists.
Extreme or impossible values that distort every downstream metric.
Choose a path
3CLEAN
12 stepsQuery-Based
Query-Based
SQL Cleaning
-
Syntax
TRIM(col) · BTRIM(col, ' .')ExampleTRIM(' John Doe ') → 'John Doe'Removes leading and trailing spaces (or any characters you list) from text. -
Syntax
INITCAP(LOWER(col))ExampleINITCAP('jOHN dOE') → 'John Doe'Standardises text case, lower, upper, or Title Case for names and labels. -
Syntax
REPLACE(col, 'old', 'new')ExampleREPLACE('555-1234', '-', '') → '5551234'Swaps every occurrence of a substring, ideal for fixing known bad values. -
Syntax
REGEXP_REPLACE(col, '\s+', ' ', 'g')ExampleREGEXP_REPLACE('a b c', '\s+', ' ', 'g') → 'a b c'Cleans patterns with regex, here collapsing repeated whitespace into one space. -
Syntax
COALESCE(col, 'N/A')ExampleCOALESCE(NULL, 'N/A') → 'N/A'Replaces NULLs with a fallback value so gaps don't break reports. -
Syntax
NULLIF(col, '')ExampleNULLIF('', '') → NULLTurns empty strings into real NULLs so they are treated as missing. -
Syntax
col::numeric · CAST(col AS date)Example'42'::int + 8 → 50Converts a value to the correct data type for maths, sorting and joins. -
Syntax
TO_DATE(col, 'DD/MM/YYYY')ExampleTO_DATE('15/01/2024','DD/MM/YYYY') → 2024-01-15Parses text into a real date or number using an explicit format mask. -
Syntax
SPLIT_PART(col, ',', 1)ExampleSPLIT_PART('Doe,John', ',', 1) → 'Doe'Extracts one piece of a delimited string by its position. -
Syntax
SUBSTRING(col FROM '\d+')ExampleSUBSTRING('Order #1234' FROM '\d+') → '1234'Pulls out part of a string by position or regular expression. -
Syntax
SELECT DISTINCT col FROM t;ExampleSELECT DISTINCT country FROM users; → unique listReturns unique values, a quick way to profile and spot dirty data. -
Syntax
CASE WHEN col='Y' THEN true ELSE false ENDExampleCASE WHEN status='Y' THEN 'Active' ELSE 'Inactive' ENDRecodes or standardises values conditionally, row by row.
BEST FOR
- Small, ad-hoc fixes
- One-off exploration
- Learning SQL fundamentals
14 stepsSet-Based ETL
Set-Based ETL
& Staging
-
Syntax
CREATE TABLE stg AS SELECT * FROM raw;ExampleCREATE TABLE stg_orders AS SELECT * FROM orders_raw;Load raw data into a scratch table so you clean without touching the source. -
Syntax
INSERT INTO clean SELECT ... FROM stg;ExampleINSERT INTO customers(name, email) SELECT INITCAP(TRIM(name)), LOWER(email) FROM stg;Move cleaned rows into the final table in one set-based operation. -
Syntax
UPDATE t SET col = TRIM(col) WHERE col <> TRIM(col);ExampleUPDATE users SET email = LOWER(email) WHERE email <> LOWER(email);Fix values across thousands of rows at once, filtered by a condition. -
Syntax
DELETE FROM t a USING t b WHERE a.ctid < b.ctid AND a.key = b.key;ExampleDELETE FROM users a USING users b WHERE a.ctid < b.ctid AND a.email = b.email;Remove duplicate rows using ctid to keep exactly one of each. -
Syntax
WITH d AS (SELECT ...) SELECT * FROM d;ExampleWITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) rn FROM users ) SELECT * FROM ranked WHERE rn = 1;Break complex cleaning into readable, chained steps. -
Syntax
ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC)ExampleSELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY updated_at DESC) AS rn FROM users; -- keep rn = 1Rank rows per key so you can keep the latest and drop the rest. -
Syntax
LAG(col) OVER (ORDER BY id)Exampleamount - LAG(amount) OVER (ORDER BY day) → day-over-day changeCompare across rows to spot gaps, jumps and anomalies. -
Syntax
JOIN ref r ON t.code = r.codeExampleSELECT t.*, r.country_name FROM sales t JOIN countries r ON t.iso = r.iso;Standardise codes and labels against a trusted reference table. -
Syntax
GROUP BY key HAVING COUNT(*) > 1ExampleSELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1; → duplicatesAggregate for profiling and surface duplicates in one query. -
Syntax
ALTER TABLE t ADD CONSTRAINT uq UNIQUE (key);ExampleALTER TABLE users ADD CONSTRAINT uq_email UNIQUE (email);Enforce NOT NULL and UNIQUE rules so bad data can't get back in. -
Syntax
ALTER TABLE t ADD CHECK (age BETWEEN 0 AND 120);ExampleALTER TABLE people ADD CHECK (age BETWEEN 0 AND 120);Reject out-of-range values at the database level, automatically. -
Syntax
INSERT ... ON CONFLICT (key) DO UPDATE SET ...;ExampleINSERT INTO users(email, name) VALUES ('a@x.com','A') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;Insert or update in a single statement, avoiding duplicate keys. -
Syntax
CREATE VIEW clean_v AS SELECT ...;ExampleCREATE VIEW active_users AS SELECT * FROM users WHERE status = 'active';Expose a clean, reusable version of the data without copying it. -
Syntax
CREATE MATERIALIZED VIEW mv AS SELECT ...;ExampleCREATE MATERIALIZED VIEW daily_sales AS SELECT day, SUM(amount) FROM orders GROUP BY day; -- REFRESH MATERIALIZED VIEW daily_sales;Cache heavy cleaning results and refresh them on demand.
BEST FOR
- Medium and large tables
- Repeatable ETL pipelines
- Business reporting
12 stepsProcedural
Procedural
PL/pgSQL
-
Syntax
CREATE FUNCTION clean(t text) RETURNS text AS $$ ... $$ LANGUAGE plpgsql;ExampleCREATE FUNCTION norm(t text) RETURNS text AS $$ BEGIN RETURN INITCAP(TRIM(t)); END; $$ LANGUAGE plpgsql;Wrap reusable cleaning logic in a callable function. -
Syntax
DO $$ BEGIN ... END $$;ExampleDO $$ BEGIN UPDATE users SET email = LOWER(email); END $$;Run a one-off procedural script without creating a permanent function. -
Syntax
CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION f();ExampleCREATE TRIGGER clean_email BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION tidy_email();Auto-clean every row the moment it is inserted or updated. -
Syntax
NEW.email := LOWER(TRIM(NEW.email)); RETURN NEW;ExampleCREATE FUNCTION tidy_email() RETURNS trigger AS $$ BEGIN NEW.email := LOWER(TRIM(NEW.email)); RETURN NEW; END; $$ LANGUAGE plpgsql;Standardise column values inside a BEFORE trigger. -
Syntax
FOR r IN SELECT ... LOOP ... END LOOP;ExampleFOR r IN SELECT id, email FROM users LOOP UPDATE users SET email = LOWER(r.email) WHERE id = r.id; END LOOP;Iterate over rows for row-by-row processing when a set query won't do. -
Syntax
BEGIN ... EXCEPTION WHEN others THEN ... END;ExampleBEGIN INSERT INTO clean SELECT * FROM stg; EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'dupe skipped'; END;Catch and log errors so one bad row doesn't stop the whole job. -
Syntax
EXECUTE format('UPDATE %I SET ...', tbl);ExampleEXECUTE format('UPDATE %I SET name = TRIM(name)', tbl);Build and run SQL for any table or column at runtime, safely quoted. -
Syntax
CREATE DOMAIN email AS text CHECK (VALUE ~ '^.+@.+$');ExampleCREATE DOMAIN email AS text CHECK (VALUE ~ '^.+@.+$'); CREATE TABLE t (addr email);Define a reusable, validated type once and apply it everywhere. -
Syntax
CREATE TYPE status AS ENUM ('active','inactive');ExampleCREATE TYPE status AS ENUM ('active','inactive'); ALTER TABLE users ADD state status;Constrain a column to a fixed set of valid values. -
Syntax
SELECT cron.schedule('nightly','0 2 * * *','CALL clean_all();');ExampleSELECT cron.schedule('dedupe','0 3 * * *', 'DELETE FROM stg a USING stg b WHERE a.ctid < b.ctid AND a.key = b.key');Automate cleaning to run on a schedule, no manual step. -
Syntax
CREATE EXTENSION IF NOT EXISTS pg_trgm;ExampleSELECT * FROM users WHERE name % 'Jon'; -- fuzzy-matches 'John', 'Jon'…Add fuzzy matching (pg_trgm), unaccent and more for advanced cleaning. -
Syntax
-- V1__clean.sql (versioned & repeatable)Example-- V2__dedupe_users.sql (run in order by Flyway / Liquibase)Version-control reusable cleaning scripts across every environment.
BEST FOR
- Enterprise pipelines
- Advanced automation
- Custom, governed rules
4VALIDATE
Cleaned & Validated Data
Consistent, deduplicated, correctly typed and analysis-ready
5ANALYZE
Analysis & Visualization
Query, view and serve clean data to any reporting tool
GROUP BY, SUM, AVG and window queries summarise clean data on the fly.
Saved queries that serve analysis-ready datasets to any tool.
Pre-computed, refreshable result sets for fast, heavy reporting.
Connect Metabase, Power BI, Tableau or Superset straight to Postgres.
6DECIDE
Business Intelligence & Decision Making
The payoff: clean data drives confident action
Formatted, refreshable outputs that keep stakeholders aligned on the same numbers.
Track key metrics against targets at a glance, so problems surface early.
Confident, data-backed choices by leadership instead of gut feel.
Spot the opportunities and trends that drive measurable, repeatable growth.
Align long-term direction with evidence rather than assumption.
★LEARNING JOURNEY
ROADMAPyour path from raw tables to automated, governed data
1Raw Data
2Load to Postgres
3Query Cleaning
4Set-Based ETL
5PL/pgSQL Automation
6Views & Analytics
7Dashboards
8Business Decisions
Data Cleaning with SQL · PostgreSQL · Query-Based SQL · Set-Based ETL · Procedural PL/pgSQL
Data Cleaning with SQL · PostgreSQL
Created by Ada Innocent Ada, NCMD
for Data Tribe · by Awake to Power Communications
© Awake to Power Communications 2026