Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 18 – Using SQL from application code

Testing SQL queries and data quality

Test SQL queries with fixtures and expected result sets, check data for nulls, duplicates and orphans, and test on the engine you run in production.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: Two kinds of check · A query test, run · Which queries to test first. With a pass: 3 in-depth parts and 3 interview answers, about 20 minutes.

What you will learn

  • Test queries with fixtures and expected result sets
  • Write data-quality checks for nulls, duplicates and orphans
  • Avoid false confidence from testing on a different engine

Before you start

A query can run without an error and still return the wrong numbers, so SQL needs tests like any other code. Testing SQL queries means running each query on a fixture, a few rows small enough that you know the right answer by hand, and comparing the rows it returns with the rows you expect. Checks of data quality do the opposite job: they run on real data, and each one is a query that lists the rows breaking one rule, such as a missing value, a duplicate or an order whose customer does not exist. A check that returns no rows passes. Both are cheap to write, and both are only worth something on the engine that runs in production: a test that passes on SQLite says little about the same query on PostgreSQL.

Two kinds of check

Query tests and data-quality checks
Criterion Query testData-quality check
Runs on a fixture of a few rows, built for the testreal data: production, a copy or a new load
Checks the query: does it compute the right answer?the data: does every row obey a rule?
Passes when the rows match the expected rowsit returns no rows
Runs in CI, on every change to the queryafter every load, and on a schedule
Catches wrong joins, NULLs, ties, empty groupsduplicates, orphans, missing and impossible values
  • A query test checks that a query computes the right answer on rows whose answer you know.
  • A data-quality check is a query that lists the rows of real data breaking one rule.
  • A constraint such as NOT NULL, UNIQUE, CHECK or a foreign key stops a bad row before it is written.

Testing SQL queries protects the logic; checking data quality protects the input. A correct query over duplicated or orphaned rows still gives wrong totals, so data quality needs its own checks.

Constraints are the cheapest tests of all, because they run on every write and cost nothing to maintain. Checks catch what constraints cannot: data loaded from an export without its constraints, rules across several rows such as “no two customers share an e-mail address, ignoring case”, and rules that are true most of the time, such as “a delivered order has at least one item”.

A query test compares a query's rows on a fixture with expected rows; a data-quality check lists rows of real data that break a rule.A query testA data-quality checkFixturesmall tables, answer knownThe query under testSame rows as the expected ones?in any order, unless ORDER BYis part of the answerReal dataproduction or a copyCheck querythe rows that break one ruleNo rows: the rule holdsany row: report it or alertrun onactual rows

Two kinds of check

Text description of the diagram

The figure has two parts, one above the other.

A query test: the query under test runs on a fixture, small tables whose answer is known. Its actual rows are compared with the expected rows, which were worked out by hand: are they the same rows, in any order unless ORDER BY is part of the answer?

A data-quality check: a check query runs on real data, from production or a copy, and returns the rows that break one rule. If it returns no rows, the rule holds; if it returns any row, the check reports it or raises an alert.

A query test, run

The test below needs nothing but Python’s sqlite3. check.py builds a fresh database from a fixture, runs the query and compares its rows with the expected rows. Without ORDER BY a result has no defined order, so the rows are compared as a multiset: each row must appear as often as expected, in any order.

Two versions of a report, one fixture

tests/orders_per_customer.py

from check import report, run, same_rows

# A fixture small enough to work out the answer by hand: Farah has no orders at all.
FIXTURE = """
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL);
CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, status TEXT NOT NULL);
INSERT INTO customers VALUES (1, 'Asha', 'Pune'), (2, 'Farah', 'Pune'), (3, 'Lakshmi', 'Pune'), (4, 'Ravi', 'Kochi');
INSERT INTO orders VALUES (101, 1, 'delivered'), (102, 1, 'placed'), (103, 3, 'delivered'), (104, 4, 'delivered');
"""
EXPECTED = [("Asha", 2), ("Farah", 0), ("Lakshmi", 1)]

# Two versions of "orders per customer of a city", the customers without orders included.
VERSIONS = {
    "count(*)": """SELECT c.name, count(*) FROM customers c
                   LEFT JOIN orders o ON o.customer_id = c.customer_id
                   WHERE c.city = ? GROUP BY c.customer_id""",
    "count(o.order_id)": """SELECT c.name, count(o.order_id) FROM customers c
                            LEFT JOIN orders o ON o.customer_id = c.customer_id
                            WHERE c.city = ? GROUP BY c.customer_id""",
}
for name, sql in VERSIONS.items():
    report(name, same_rows(run(FIXTURE, sql, ("Pune",)), EXPECTED))

Output

count(*): FAIL, missing [('Farah', 0)], unexpected [('Farah', 1)]
count(o.order_id): pass

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 orders_per_customer.py

tests/check.py

"""A small helper for testing queries: run a query on a fixture and compare its rows with the rows you expect."""
import sqlite3
from collections import Counter


def run(fixture, sql, params=()):
    """Builds a fresh in-memory database from the fixture's SQL and returns the query's rows."""
    conn = sqlite3.connect(":memory:")
    conn.executescript(fixture)
    rows = conn.execute(sql, params).fetchall()
    conn.close()
    return rows


def same_rows(actual, expected, ordered=False):
    """None when the rows match, else what differs. Without ORDER BY a result has no order, so rows are compared as
    a multiset: every row must appear as often as expected, in any order."""
    if ordered:
        return None if actual == expected else f"expected {expected}, got {actual}"
    missing = Counter(expected) - Counter(actual)
    extra = Counter(actual) - Counter(expected)
    if not missing and not extra:
        return None
    return f"missing {sorted(missing.elements())}, unexpected {sorted(extra.elements())}"


def report(name, problem):
    print(f"{name}: {'pass' if problem is None else 'FAIL, ' + problem}")

The fixture has one customer who matters more than the others: Farah, who has no orders. Both versions keep her with a LEFT JOIN, but count(*) counts the joined row whose order columns are NULL, so it reports 1 order where there are none. count(o.order_id) counts only real orders and passes. A fixture of customers who all have orders would have passed both versions, which is the general lesson: a test is only as good as its awkward rows. Write the expected rows by hand, from the fixture, never by copying what the query printed.

The same comparison works in any test runner. With pytest, a test function builds the fixture, runs the query and asserts that Counter(actual) == Counter(expected). For a query whose order is part of the answer, such as “the top 3 products”, compare lists instead, and give the query a tie-breaker so that the order is fully defined.

Which queries to test first

Not every query needs its own test. Start with the queries whose mistakes cost the most and are least likely to be noticed: reports that feed decisions or invoices, anything that adds up money, queries with outer joins, grouping or window functions, and queries over columns that may hold NULL. A wrong total on a dashboard can stay unnoticed for months, while a wrong lookup on a page is usually reported the same day. Give each such query one test with an ordinary fixture and one with the awkward rows, and run them in the same pipeline as the rest of the code, so that a change to the query, the schema or the engine version runs them again.

Check yourself

3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 3 A query has no ORDER BY. How should a test compare its rows with the expected rows?

    Choose one answer.

    Show the answer to question 1

    Answer: As a multiset: the same rows, each as often as expected, in any order

    Without ORDER BY the database may return rows in any order, and the order can change with an index or a new version. Duplicates matter, though: a join that doubles rows must fail the test, so a set comparison is too weak.

  2. Question 2 of 3 A report of orders per customer uses LEFT JOIN and count(*). What does it show for a customer with no orders?

    Choose one answer.

    Show the answer to question 2

    Answer: 1, because the customer's row with NULL order columns is counted

    The LEFT JOIN keeps the customer with NULLs in the order columns, and count(*) counts that row. count(o.order_id) counts only non-NULL values and gives 0, which the lesson's test checks with a fixture that has such a customer.

  3. Question 3 of 3 A data-quality check is written as a query. When does it pass?

    Choose one answer.

    Show the answer to question 3

    Answer: When it returns no rows

    A check lists the rows that break its rule, so an empty result means the rule holds. Any row it returns is evidence you can act on: the key of a duplicate, an orphaned item, an order without a customer.

Edge-case fixtures that break reports, reproduced Pro: opens with a Pro, Premium or Ultimate pass

Most wrong reports are right on ordinary data and wrong on one awkward row. This part runs four of those awkward rows against a plausible query and a correct one, then turns them into a checklist for any fixture.

In this part:

  • Four awkward rows, four plausible queries
  • A checklist for fixtures
  • Boundaries of a range, with numbers

1 runnable example (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

A data-quality pack for mart, run on a dirty copy Pro: opens with a Pro, Premium or Ultimate pass

Checks earn their keep on data that arrived without its constraints: an import, a copy from another system, a table someone filled by hand. This part runs eight checks on a dirty copy of mart, maps each to the constraint that would have prevented it, and has you write three of them.

In this part:

  • Eight checks in one query
  • Checks on the whole table: freshness and volume
  • Your turn: three checks in one query
  • Three data-quality checks in one query
    • Results of the sample tests

1 runnable example (SQL) · 1 exercise with sample tests · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Tests that pass on SQLite and fail on PostgreSQL, reproduced Pro: opens with a Pro, Premium or Ultimate pass

A fast test on SQLite is tempting when production runs PostgreSQL, and it hides exactly the bugs that matter. This part runs six statements on both engines and shows which differences a test on the wrong engine cannot see.

In this part:

  • The same six statements on both engines
  • Running tests on the production engine

2 runnable examples (Python, SQL) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    When a test checks a query's rows, why compare them as a multiset rather than as a list or a set?

    A query without ORDER BY has no defined order: the database may return its rows in any order, and the order can change after a new index, a new version or more data, so a test that compares lists fails for no real reason. A set comparison goes too far the other way, because it ignores how often a row appears, and a join that doubles rows is exactly the bug a test must catch. A multiset compares each row with its count, in any order, which matches what a query promises. When the order is part of the answer, as in a top 10, compare lists, and give the query a tie-breaker so that its order is fully defined.

  2. Mid to senior

    A sales report's SQL has unit tests that run on SQLite, and all of them pass. On PostgreSQL in production, one query fails with "must appear in the GROUP BY clause", and another lists the rows with no region last instead of first. Explain both, fix them, and say what you change in the tests.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    After last quarter's orders were imported from an old system, the revenue dashboard shows almost twice the figure the finance team has. Nobody changed the dashboard's query. How do you find the cause with SQL, repair the data and make sure it cannot happen again?

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    You take over 40 reporting queries that feed finance dashboards and have no tests at all. You have two weeks before a database upgrade from PostgreSQL 16 to 18. How do you plan the testing, and what do you have in place at the end?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Test a query on a fixture whose answer you know, and compare rows as a multiset unless the order is part of the answer.
  • A data-quality check lists the rows that break one rule; constraints stop most of them at write time.
  • Test on the engine that runs in production.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.