Skip to content

Grokem · https://www.grokem.com/data/data-management/data-quality/data-quality-checks-checklist

  • 4 min read

0 sec

Bad data rarely announces itself. It sits in a table until a dashboard looks odd, a customer complains or a decision is made on the wrong number. Automated checks move that discovery to the moment the data arrives.

The simplest useful design is a list of checks where each check is a query that returns the bad rows. An empty result means it passes. That one rule makes checks easy to write, easy to read and easy to show to the person who has to fix the data.

A runnable example

This script builds two small tables with problems planted in them (a missing total, a duplicate id, a misspelt status, an order for a customer that does not exist) and runs the checks.

python
import sqlite3
from datetime import date

conn = sqlite3.connect(":memory:")
conn.executescript(
    """
    CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT);
    CREATE TABLE orders (id INTEGER, customer_id INTEGER, status TEXT, total REAL, loaded_on TEXT);
    INSERT INTO customers VALUES (1, 'ana@example.com'), (2, 'ben@example.com');
    INSERT INTO orders VALUES
      (1, 1, 'paid',    40.0, '2025-03-10'),
      (2, 2, 'shipped', 15.5, '2025-03-10'),
      (3, 9, 'paid',    22.0, '2025-03-10'),
      (4, 1, 'refnded', 10.0, '2025-03-10'),
      (5, 2, 'paid',    NULL, '2025-03-10'),
      (5, 2, 'paid',    12.0, '2025-03-10');
    """
)

# each check is a query that returns the BAD rows: an empty result means the check passes
CHECKS = {
    "orders.total is never null":
        "SELECT id FROM orders WHERE total IS NULL",
    "orders.id is unique":
        "SELECT id FROM orders GROUP BY id HAVING COUNT(*) > 1",
    "orders.status is an accepted value":
        "SELECT id, status FROM orders WHERE status NOT IN ('new', 'paid', 'shipped', 'refunded', 'cancelled')",
    "every order has a customer":
        "SELECT o.id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL",
    "no negative totals":
        "SELECT id FROM orders WHERE total < 0",
}

failures = 0
for name, sql in CHECKS.items():
    bad = conn.execute(sql).fetchall()
    status = "ok  " if not bad else "FAIL"
    failures += bool(bad)
    print(f"{status} {name}" + (f"  -> {bad}" if bad else ""))

# freshness and volume are checks too
latest = conn.execute("SELECT MAX(loaded_on) FROM orders").fetchone()[0]
age_days = (date(2025, 3, 12) - date.fromisoformat(latest)).days
print(f"{'ok  ' if age_days <= 1 else 'FAIL'} newest load is {age_days} day(s) old")
failures += age_days > 1

raise SystemExit(1 if failures else 0)

Its output, and a non-zero exit code that tells a scheduler the run failed:

text
FAIL orders.total is never null  -> [(5,)]
FAIL orders.id is unique  -> [(5,)]
FAIL orders.status is an accepted value  -> [(4, 'refnded')]
FAIL every order has a customer  -> [(3,)]
ok   no negative totals
FAIL newest load is 2 day(s) old

Each failure names the table and column and lists the offending ids, so the person who gets the alert knows where to look. The exit code matters: a scheduler or CI job can stop on it.

The checklist

The ten checks in the list above cover most real failures. The first five are about the shape of the data, and the next five about whether the pipeline is behaving.

Choosing what a failure does

Not every check deserves the same response. A useful way to classify them:

  • Block: the data would corrupt downstream tables, such as duplicate keys in a table that feeds revenue. Stop the load.
  • Quarantine: a handful of rows are bad but the rest are fine. Move the bad rows to a rejects table and continue.
  • Warn: something is unusual but plausible, such as a volume 30% below normal. Alert and let a person decide.

Write the choice next to each check. When a check starts failing at 2 a.m., the decision has already been made calmly in daylight.

Keeping checks useful

  • Start with the few that matter. Ten checks on the tables that feed decisions beat three hundred nobody reads.
  • Treat a flapping check as a bug. One that fails and recovers every other day trains people to ignore alerts. Fix the data or the threshold.
  • Add a check whenever data goes wrong. Every incident should leave a check behind so the same problem cannot return unnoticed.
  • Put constraints in the database where you can. A NOT NULL, UNIQUE or CHECK constraint stops bad data at the door. Pipeline checks cover the rules a constraint cannot express.

Checklist

  • Check required columns for NULLs: a missing customer id or amount is almost never intended.
  • Check that keys are unique, and fail if a duplicate appears. Duplicate keys double-count everything downstream.
  • Check values against an accepted list (statuses, countries, currencies). A typo such as 'refnded' is a silent new category.
  • Check referential integrity: every foreign key points at a row that exists. Orphans mean a join will drop or misplace data.
  • Check ranges: no negative quantities, no dates in the future, percentages between 0 and 100.
  • Check freshness: the newest record is recent enough. A pipeline that quietly stops loading looks healthy until someone notices stale numbers.
  • Check volume: the latest load's row count is within a normal band of recent days. A drop to zero, or a doubling, usually means a problem upstream.
  • Check totals against the source: revenue in the warehouse matches the finance system within an agreed tolerance.
  • Check schema changes: a column added, removed or retyped upstream should fail loudly rather than flow through.
  • Decide what a failure does: block the load, quarantine the bad rows, or alert and continue. Write it down per check, and send alerts to someone who owns the fix.

Frequently asked questions

Where should the checks run?

Close to where data lands, and again on the finished tables people query. Catching a problem at the door is cheaper than finding it in a board report.

Should a failed check always stop the pipeline?

Not always. Stopping suits errors that would corrupt downstream tables. For minor ones, quarantining the bad rows and alerting keeps the good data flowing.

References

From Grokem: https://www.grokem.com/data/data-management/data-quality/data-quality-checks-checklist

Lesson 5 of 6 in Data pipelines you can trust

How did this strike you?

Be the first to react – it is anonymous.

Community

Questions about this?

Ask in Questions and help and get help from other readers.

Ask a questionBrowse Questions and help

Comments

No comments yet. Share what worked for you, or ask a question.

Keep going

All in topic

Choose what we may use. You can change this at any time with “Privacy choices” at the foot of every page. Details are in the Cookie policy and the Privacy policy.

Strictly necessary

Remembers your theme, the lessons you marked as done and this choice. It stays on your device, tracks nothing and cannot be switched off.

Always on
What exactly is collected, and why

Grokem’s own counts

Collects
The pages you open and how long you stay, how far you scroll, where you came from, your device type, browser, screen size, language and time zone. Your IP address is turned into a code that changes every day; it is never stored as it is.
Why
To see how long lessons are read and where readers leave. Not shared with anyone.
Stored
A random visitor ID, a visit ID and the time of your last page view in your browser’s storage (gr_vid, gr_sid, gr_last). No name or email is attached.