EduFlow
SQL for Analysts
0%

Section 4 · SQL in the Real World

Data-Quality Checks

16 min3 min readSQL exercise

Lesson at a glance

Write data-quality checks as queries that return the offending rows, bundle them into a reusable suite, and investigate each failure as a question for the business rather than a bug to hide.

  1. 1A check is a query that returns the bad rows
  2. 2The five families of checks
  3. 3A suite you can rerun
  4. 4Failures are questions, not verdicts

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Karim is presenting 2025 results to the board next week. His request is short and serious: “Before these numbers go on a slide, how sure are we about the data?” Every query you’ve written assumes the data means what the documentation says. This lesson is about checking that assumption systematically, so that “I’m confident” is backed by queries, not feelings.

A check is a query that returns the bad rows

The most useful habit in data quality is to phrase each rule as a query that returns the rows breaking it. An empty result means the rule holds; anything else is evidence you can show someone. Here’s the rule “a product sits in a sub-category, never directly in a top-level one”, from the data documentation:

SQL
SELECT p.product_id, p.name, c.name AS category
FROM products AS p
JOIN categories AS c ON c.category_id = p.category_id
WHERE c.parent_id IS NULL;

Two rows: the Picnic Blanket filed directly under Outdoor and the Bamboo Cutting Board under Kitchen. That explains the odd “Kitchen” and “Outdoor” rows in the category revenue table from Section 2. A check that returns rows you can name is far more persuasive than a count.

The five families of checks

Most rules fall into five families. Running at least one check from each catches the large majority of problems:

Family Question Cartwheel example
Uniqueness Is the key really unique? Duplicate emails, ignoring case
Completeness Are values present where they must be? Tickets with no agent_id
Validity Are values in their allowed range? Discount between 0 and 0.25
Referential integrity Does every reference point to a real row? Order items whose order doesn’t exist
Consistency Do related columns and tables tell the same story? Orders marked ‘returned’ with no return record; open tickets that already have a rating

Database constraints (PRIMARY KEY, NOT NULL, CHECK, FOREIGN KEY) enforce some of these at write time, if they were declared and, in SQLite’s case, if foreign-key enforcement was switched on. Analysts rarely control that, and consistency rules across tables usually can’t be expressed as constraints at all. So you check.

A suite you can rerun

One check is a query; a dozen checks are a suite. Give each a name, count its failures, and stack them with UNION ALL:

SQL
SELECT 'customers: duplicate email (any case)' AS check_name, COUNT(*) AS failures
FROM (SELECT LOWER(email) FROM customers GROUP BY LOWER(email) HAVING COUNT(*) > 1)
UNION ALL
SELECT 'order_items: no matching order', COUNT(*)
FROM order_items AS oi
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.order_id = oi.order_id)
UNION ALL
SELECT 'order_items: discount outside 0-0.25', COUNT(*)
FROM order_items
WHERE discount NOT BETWEEN 0 AND 0.25
UNION ALL
SELECT 'products: filed under a top-level category', COUNT(*)
FROM products AS p
JOIN categories AS c ON c.category_id = p.category_id
WHERE c.parent_id IS NULL
UNION ALL
SELECT 'orders: returned but no return record', COUNT(*)
FROM orders AS o
WHERE o.status = 'returned'
  AND NOT EXISTS (
    SELECT 1 FROM order_items AS oi
    JOIN returns AS r ON r.order_item_id = oi.order_item_id
    WHERE oi.order_id = o.order_id)
UNION ALL
SELECT 'tickets: rated while still open', COUNT(*)
FROM support_tickets
WHERE closed_at IS NULL AND satisfaction IS NOT NULL;

Three checks fail. Each compound part must return the same number of columns; the first SELECT names them. UNION ALL keeps every row, whereas UNION would deduplicate, which costs time and could merge two checks that happen to share a name and count. Save the suite next to your analysis and run it every time the data is refreshed; a check that passed in October can fail in December.

Failures are questions, not verdicts

Now read the failures like an analyst, not a linter:

  • Two returned orders without return records were both placed in the last two weeks of December 2025. That smells like a process delay (the return is logged when the parcel arrives back), not corruption. Ask operations.
  • Three open tickets with a satisfaction score were opened in the last days of December. Perhaps the survey goes out before the ticket is formally closed. Ask Julia.
  • Two misfiled products are almost certainly a catalogue error. Report it to whoever maintains the catalogue, and in the meantime decide how your category report treats them.

There’s one more failure hiding in this data, and it’s the most interesting one: products that appear in orders before their recorded launch date. Finding it is your exercise.

Next you’ll look at the other side of trust, performance: why some of these queries are instant and others crawl, and how to see what the database is actually doing.

Key takeaways

  • Phrase every data-quality check as a query that returns the rows breaking a rule, so zero rows means the check passes.
  • Cover uniqueness, completeness, valid ranges, referential integrity and cross-table consistency; each catches a different kind of problem.
  • Bundle checks into one UNION ALL query with a name and a failure count per check, and run it before any important analysis.
  • A failing check is a question to take to the data’s owner, not something to filter away silently; record what you found and what you decided.

Further reading

Check your understanding

4 questions

Question 1 of 4

Which query is the best data-quality check for “every order item belongs to an existing order”?

Question 2 of 4

Two orders have status ‘returned’ but no row in returns. Both were placed in the last two weeks of December 2025. What’s the most sensible next step?

Question 3 of 4

Why use UNION ALL rather than UNION to combine check results into one suite?

Question 4 of 4

Three discontinued products (is_active = 0) still have orders in December 2025. What kind of problem is this?

Practice

SQL exercise

Sold before launch

Write this check from scratch. Rule: a product can’t be ordered before its launched_on date.

Return one row per product that breaks the rule, with product_id, name, launched_on, first_sold (the date of its earliest order, as ‘YYYY-MM-DD’) and lines (how many order lines were placed before the launch date). Only count lines from before launch. Row order doesn’t matter.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Data-quality check: products sold before their launch date
-- Write it from scratch. One row per offending product with:
--   product_id, name, launched_on, first_sold (YYYY-MM-DD), lines
SELECT product_id, name, launched_on
FROM products;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.