Module 3 · Advanced SQL
Data quality checks in SQL
Profile a table before you trust it, test keys, relationships and business rules, and investigate suspicious rows instead of deleting them.
About 20 minutes
The problem
You've just been given access to Harbourline's database, and finance wants a receivables report by Friday. Before you write a single report query, ask the question every experienced analyst asks first: can I trust this data?
A report built on data nobody checked can be wrong in ways no query will warn you about: customers counted twice, shipments marked Delivered with no delivery date, payments that belong to no shipment. Twenty minutes of checks can save the week you'd otherwise spend explaining a wrong number to finance.
The concept
Data quality checks are short queries that test what you're assuming. Run them on any new table, and again whenever the data is refreshed.
| Check | Question | Typical query |
|---|---|---|
| Profile | How many rows, NULLs, distinct values, min and max? | COUNT(*), COUNT(col), COUNT(DISTINCT col), MIN, MAX |
| Keys | Is the ID really unique? | COUNT(*) against COUNT(DISTINCT id) |
| Relationships | Does every foreign key point at a real row? Does every parent have children? | NOT EXISTS, LEFT JOIN … IS NULL |
| Business rules | Do the values make sense together? | e.g. Delivered shipments have both dates; delivery is after shipping |
| Duplicates | Are rows repeated under different IDs? | GROUP BY the natural key HAVING COUNT(*) > 1 |
| Reconciliation | Do totals agree with another source? | e.g. payments never exceed the charge |
A failed check is a question, not a verdict. When a check finds rows, look at them before doing anything. Some are errors. Some are perfectly legitimate and tell you something about the business. Write down what you found and what you decided, so the next person doesn't repeat the investigation.
Example
A business-rule check: each status should come with the right dates. Booked and Cancelled shipments haven't shipped, In transit ones have a ship date only, and Delivered ones have both.
SELECT
status,
COUNT(*) AS shipments,
COUNT(ship_date) AS with_ship_date,
COUNT(delivery_date) AS with_delivery_date
FROM shipments
GROUP BY status;Every status passes. Now a duplicate check on the natural key: the same customer booking the same route on the same day with the same number of containers.
SELECT s.*
FROM shipments AS s
JOIN (
SELECT customer_id, route_id, booking_date, containers
FROM shipments
GROUP BY customer_id, route_id, booking_date, containers
HAVING COUNT(*) > 1
) AS d
ON d.customer_id = s.customer_id
AND d.route_id = s.route_id
AND d.booking_date = s.booking_date
AND d.containers = s.containers;Two shipments match: 101206 and 101207, both booked by customer 27 on 2 October 2025. They aren't duplicates. They shipped on different days with different weights and different charges, so they're two real containers that happened to be booked together. Deleting one would have understated that customer's revenue by ₦3.7 million.
Walkthrough
- Profile the customers table:
SELECT COUNT(*), COUNT(DISTINCT customer_id), COUNT(account_manager_id), MIN(signup_date), MAX(signup_date) FROM customers;. 120 customers, unique IDs, 8 with no account manager. - Check a relationship from the other side: customers with no shipments at all. There are 15. They signed up but never booked. Is that a data problem, or a sales opportunity?
- Check a timing rule: does any customer's first booking come before their signup date? One does: Horizon Foods Plc (customer 79) signed up on 27 January 2025 but first booked on 12 January. That's probably a late account set-up, so it's worth noting but harmless.
- Check payments against shipping: 59 payments are dated before the shipment even left. Are they prepayments, or payments recorded on the wrong date? That's a question for finance, not something to "fix" in SQL.
- Write the results in a short data-quality note: check, result, decision.
Practice
Practice
List the customers who have never booked a shipment. Show customer_id, company_name and signup_date, ordered by signup_date. Use NOT EXISTS.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Practice
Reconcile the money on delivered shipments. Show one row: charged (total freight_charge), received (total payments on those shipments) and outstanding (charged − received).
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Challenge
Challenge · optional
List the payments dated before the shipment's ship_date. Show payment_id, shipment_id, payment_date, ship_date and method, ordered by payment_id.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
More practice
Drill · optional
Profile the shipments table in one row: total_rows, distinct_ids, min_booking, max_booking, min_containers, max_containers.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Drill · optional
Find customers whose first booking is earlier than their signup_date. Show customer_id, company_name, signup_date and first_booking.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Drill · optional
Some shipments are paid in instalments. List the shipments with more than one payment: shipment_id, payments (the count) and paid (the total), ordered by payments descending then shipment_id. Top 10 only.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Check your understanding
Answer every question to check.