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.
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
What you’ll learn · Before you start
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
On this page
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
| Criterion | Query test | Data-quality check |
|---|---|---|
| Runs on | a fixture of a few rows, built for the test | real 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 rows | it returns no rows |
| Runs | in CI, on every change to the query | after every load, and on a schedule |
| Catches | wrong joins, NULLs, ties, empty groups | duplicates, 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,CHECKor 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”.
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.
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}") Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
-
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.
-
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.
-
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?
-
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?
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
- SQLite: The SELECT statement (result order without ORDER BY) (SQLite)
- SQLite: Datatypes In SQLite (type affinity and comparisons) (SQLite)
- SQLite: Quirks, Caveats, and Gotchas In SQLite (SQLite)
- SQLite: Built-in Aggregate Functions (count, string_agg) (SQLite)
- PostgreSQL documentation: ORDER BY (nulls first and last) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: GROUP BY and HAVING clauses (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Pattern Matching (LIKE and ILIKE) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Constraints (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Server SQL Modes (ONLY_FULL_GROUP_BY) (Oracle Corporation)
- PostgreSQL documentation: Collation Support (The PostgreSQL Global Development Group)
- pytest documentation (pytest-dev)
- PGlite: What is PGlite (ElectricSQL)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress