SQL for Testers
A tester does not need to design databases, but does need to ask them precise questions: did this order get written correctly, do the totals add up, is anything orphaned or duplicated, are required values present. This page gives those questions as working SQL against one small, fictional schema — customers, orders, order items and payments — so you can adapt them to your own tables.
Every integrity check is written so that a correct database returns zero rows, and any row returned is the evidence for a bug report; look-ups of a single change and count-and-sum summaries instead return values you compare with the expected result. The queries use syntax that runs on PostgreSQL, MySQL 8 and SQLite unless a note says otherwise, and the last sections cover dialect differences and turning ad-hoc queries into a reusable suite.
Before you run anything: safe practice
A SQL client connected to a shared database is a powerful tool and an easy way to cause an incident. Verification is reading, so most of the time you should not be able to write at all. When you do need to change data on your own environment, prove the scope of the change before you make it.
- Use a read-only account for verification. If your account can write, one slip becomes an outage or lost test data for the whole team.
- Never run UPDATE or DELETE on a shared environment (staging, UAT, a shared test database) without the agreement of whoever owns it. Announce it, and keep the exact statement in the ticket.
- Before any UPDATE or DELETE, run the same WHERE clause as SELECT COUNT(*) and check the number. Then run the change inside a transaction and compare the rows-affected count before you COMMIT.
- While exploring a large table, add LIMIT (or your dialect’s equivalent) and filter on indexed columns such as the primary key or a date range.
- Do not browse production personal data for convenience. If production access is part of your role, follow the access and logging rules that come with it.
-- On your own test database only
BEGIN;
SELECT COUNT(*) AS will_change
FROM orders
WHERE status = 'PENDING' AND created_at < '2026-01-01';
-- expect: 3
UPDATE orders
SET status = 'CANCELLED'
WHERE status = 'PENDING' AND created_at < '2026-01-01';
-- the client reports rows affected: must also be 3
ROLLBACK; -- COMMIT only when every number matchesThe example schema
The examples use four tables. A customer places orders; each order has one or more item lines and zero or more payments. The schema declares primary keys, a unique email, NOT NULL columns, CHECK constraints for status values and amounts, and foreign keys. It uses table-level constraint clauses, which PostgreSQL, MySQL 8 and SQLite all accept; remember that MySQL enforces CHECK only from 8.0.16 and SQLite enforces foreign keys only with PRAGMA foreign_keys = ON.
Three business rules at the bottom are the oracle for the checks that follow. Your own system will have different rules; the first step of database testing is always to write them down in this form, because a rule written as a sentence can be turned into a query that finds its violations.
CREATE TABLE customers (
customer_id INTEGER NOT NULL,
email VARCHAR(254) NOT NULL,
full_name VARCHAR(200) NOT NULL,
created_at TIMESTAMP NOT NULL,
CONSTRAINT pk_customers PRIMARY KEY (customer_id),
CONSTRAINT uq_customers_email UNIQUE (email)
);
CREATE TABLE orders (
order_id INTEGER NOT NULL,
customer_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
total_amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMP NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (order_id),
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (customer_id),
CONSTRAINT ck_orders_status CHECK (status IN ('PENDING', 'PAID', 'SHIPPED', 'CANCELLED')),
CONSTRAINT ck_orders_total CHECK (total_amount >= 0)
);
CREATE TABLE order_items (
order_item_id INTEGER NOT NULL,
order_id INTEGER NOT NULL,
product_sku VARCHAR(40) NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(12,2) NOT NULL,
CONSTRAINT pk_order_items PRIMARY KEY (order_item_id),
CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders (order_id),
CONSTRAINT ck_items_quantity CHECK (quantity > 0),
CONSTRAINT ck_items_price CHECK (unit_price >= 0)
);
CREATE TABLE payments (
payment_id INTEGER NOT NULL,
order_id INTEGER NOT NULL,
amount NUMERIC(12,2) NOT NULL,
status VARCHAR(20) NOT NULL,
paid_at TIMESTAMP NULL,
CONSTRAINT pk_payments PRIMARY KEY (payment_id),
CONSTRAINT fk_payments_order FOREIGN KEY (order_id) REFERENCES orders (order_id),
CONSTRAINT ck_payments_status CHECK (status IN ('CAPTURED', 'FAILED', 'REFUNDED'))
);
-- Business rules the checks below verify:
-- R1 orders.total_amount = SUM(quantity * unit_price) of its items (no tax or discount here)
-- R2 a PAID or SHIPPED order has CAPTURED payments that add up to total_amount
-- R3 a CAPTURED payment has paid_at, and paid_at is not earlier than the order's created_atVerify one change: look it up by key
After an action in the UI or API, find the affected rows by the key the application gave you — the order id in the API response, the id in the page URL — rather than by “the latest row”, which another tester may have created a second earlier. Select explicit columns, not SELECT *, so the result shows exactly the fields you are checking and does not break when a column is added.
Check the parent and the children together. After a create, the item lines must exist with the right quantities and prices; after a delete, the question is what remains, and one row with three counts answers it in a single query.
-- The API returned order_id 1042
SELECT o.order_id, o.status, o.total_amount, o.created_at, c.email
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_id = 1042;
SELECT order_item_id, product_sku, quantity, unit_price
FROM order_items
WHERE order_id = 1042
ORDER BY order_item_id;
-- After a hard delete of order 1042: nothing left behind anywhere
SELECT
(SELECT COUNT(*) FROM orders WHERE order_id = 1042) AS orders_left,
(SELECT COUNT(*) FROM order_items WHERE order_id = 1042) AS items_left,
(SELECT COUNT(*) FROM payments WHERE order_id = 1042) AS payments_left;Count and sum reconciliation
Reconciliation compares two numbers that must agree. Inside one database this is a header against its lines (R1) or orders against payments (R2); across systems it is the same count and sum computed in two places — the order database and the invoicing service, the database and a finance report. GROUP BY with HAVING returns only the groups that disagree, so an empty result is a pass.
Two details prevent false alarms. Compare money only in exact decimal types such as NUMERIC or DECIMAL; if amounts are stored as floating point, that is a finding in itself. And use COALESCE when one side may have no rows, because SUM over no rows is NULL and a comparison with NULL is never true, which would hide an order with no payments at all.
-- R1: order header total vs the sum of its lines
SELECT o.order_id, o.total_amount,
SUM(oi.quantity * oi.unit_price) AS lines_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> SUM(oi.quantity * oi.unit_price);
-- R2: paid orders vs captured payments
SELECT o.order_id, o.total_amount,
COALESCE(SUM(p.amount), 0) AS captured
FROM orders o
LEFT JOIN payments p
ON p.order_id = o.order_id AND p.status = 'CAPTURED'
WHERE o.status IN ('PAID', 'SHIPPED')
GROUP BY o.order_id, o.total_amount
HAVING COALESCE(SUM(p.amount), 0) <> o.total_amount;
-- Count and sum per status for one month, to compare with a report or another service
SELECT status, COUNT(*) AS order_count, SUM(total_amount) AS amount_sum
FROM orders
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY status
ORDER BY status;Orphan detection with LEFT JOIN … IS NULL
An orphan is a child row whose parent does not exist. A LEFT JOIN keeps every child row and fills the parent columns with NULL when no parent matches, so filtering on the parent key IS NULL lists exactly the orphans. The same shape run the other way finds parents without the children they must have, such as an order with no items.
| Item row | oi.order_idfrom order_items | o.order_idafter LEFT JOIN | Resultafter WHERE … IS NULL |
|---|---|---|---|
1 | 1042 | 1042order exists | Filtered out |
2 | 1042 | 1042order exists | Filtered out |
3 | 1099 | NULLno order 1099 | Returned: the orphanEvidence for the bug reportFail |
Illustrative rows: orders holds order 1042 and no order 1099. A correct database returns zero rows.
With foreign keys declared and enforced, orphans cannot be created — but they still appear when constraints were disabled for a bulk load, when SQLite runs without the foreign-key pragma, when a relationship spans two services’ databases, or when the constraint was never created. Avoid NOT IN with a subquery for this check: if the subquery returns a single NULL, NOT IN returns no rows at all and the check passes silently. NOT EXISTS and LEFT JOIN … IS NULL do not have this problem.
-- Items whose order does not exist
SELECT oi.order_item_id, oi.order_id
FROM order_items oi
LEFT JOIN orders o ON o.order_id = oi.order_id
WHERE o.order_id IS NULL;
-- Payments whose order does not exist
SELECT p.payment_id, p.order_id
FROM payments p
LEFT JOIN orders o ON o.order_id = p.order_id
WHERE o.order_id IS NULL;
-- The reverse: orders that have no items at all
SELECT o.order_id, o.status, o.created_at
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.order_id
WHERE oi.order_item_id IS NULL;
-- The same check written with NOT EXISTS (safe when NULLs are present)
SELECT o.order_id
FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM order_items oi WHERE oi.order_id = o.order_id
);Duplicate detection with GROUP BY … HAVING COUNT(*) > 1
Group by the value that should be unique and keep the groups that occur more than once. Normalise first when the business treats variants as the same thing: LOWER and TRIM catch emails that differ only in case or spaces. A UNIQUE constraint on the raw column does not stop those — although in MySQL the default case-insensitive collations already treat “Layla.H@example.com” and “layla.h@example.com” as equal, while PostgreSQL compares text case-sensitively.
Duplicates are also a symptom of missing idempotency: a double-clicked button or a retried request that created two orders or captured one payment twice. If your design allows split payments, compare the captured sum with the order total (R2) instead of counting rows.
-- Emails that differ only in letter case or surrounding spaces
SELECT LOWER(TRIM(email)) AS email_key, COUNT(*) AS copies
FROM customers
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1;
-- List the duplicate rows themselves, grouped together
SELECT c.customer_id, c.email, c.created_at
FROM customers c
JOIN (
SELECT LOWER(TRIM(email)) AS email_key
FROM customers
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1
) d ON d.email_key = LOWER(TRIM(c.email))
ORDER BY d.email_key, c.customer_id;
-- An order charged more than once
SELECT order_id, COUNT(*) AS captured_payments, SUM(amount) AS captured_total
FROM payments
WHERE status = 'CAPTURED'
GROUP BY order_id
HAVING COUNT(*) > 1;NULL checks and date-range checks
NULL means “unknown”, and any comparison with it — including column = NULL — is neither true nor false, so such rows silently drop out of the result. Always test with IS NULL or IS NOT NULL. Remember also that COUNT(column) skips NULLs while COUNT(*) counts rows, and that an empty string is not NULL in PostgreSQL, MySQL or SQLite (Oracle Database treats an empty string as NULL).
For dates, use half-open ranges: created_at >= the first day AND created_at < the first day of the next period. BETWEEN with an end date of 2026-09-30 misses everything after midnight on the 30th when the column holds a time. Check that both sides of a comparison use the same time zone; a CURRENT_TIMESTAMP in one zone compared with values stored in another produces false “future” rows or hides real ones.
-- Required text that is NULL or blank
SELECT customer_id, email
FROM customers
WHERE full_name IS NULL OR TRIM(full_name) = '';
-- R3: captured payments without a payment time
SELECT payment_id, order_id
FROM payments
WHERE status = 'CAPTURED' AND paid_at IS NULL;
-- R3: payments recorded before their order existed
SELECT p.payment_id, o.order_id, o.created_at, p.paid_at
FROM payments p
JOIN orders o ON o.order_id = p.order_id
WHERE p.paid_at < o.created_at;
-- Timestamps in the future (clock or time-zone defects)
SELECT order_id, created_at
FROM orders
WHERE created_at > CURRENT_TIMESTAMP;
-- One calendar month as a half-open range
SELECT COUNT(*) AS september_orders
FROM orders
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01';Dialect differences that break portable queries
The SQL standard, ISO/IEC 9075, defines the language; each engine implements it to a different degree and adds its own syntax. The checks above avoid the differences, but these are the ones testers hit most often when they copy a query from one project to another:
- Row limits — LIMIT 50 in PostgreSQL, MySQL and SQLite; SELECT TOP (50) in SQL Server; the standard FETCH FIRST 50 ROWS ONLY in PostgreSQL and in Oracle Database 12c and later.
- String concatenation — || in PostgreSQL, SQLite and Oracle; in MySQL || is a logical OR by default, so use CONCAT(), which PostgreSQL, MySQL and SQL Server all provide.
- Date arithmetic — CURRENT_DATE - INTERVAL '7 days' in PostgreSQL, CURRENT_DATE - INTERVAL 7 DAY in MySQL, date('now', '-7 days') in SQLite, DATEADD(day, -7, CAST(GETDATE() AS date)) in SQL Server. Literal ISO dates such as '2026-09-01' are the most portable choice.
- Truncating a timestamp to a day — CAST(created_at AS DATE) in PostgreSQL, MySQL and SQL Server; date(created_at) in SQLite.
- Text comparison — PostgreSQL compares text case-sensitively; MySQL’s default collations are case-insensitive; SQLite’s = is case-sensitive, and its LIKE is case-insensitive for ASCII letters only.
- Identifier quoting — "double quotes" in the standard, PostgreSQL and SQLite; `backticks` in MySQL; [brackets] in SQL Server.
- SELECT without FROM — accepted by PostgreSQL, MySQL, SQLite and SQL Server; Oracle Database traditionally needs FROM dual.
From ad-hoc queries to a reusable check suite
Once a query has found a defect, keep it. A folder of named checks, each returning zero rows when the rule holds, becomes a regression suite for data: run it after deployments, after migrations and on a schedule on staging, and wire it into the automated tests so that an API call is followed by the matching database assertion.
Cross-system comparisons often start with a list of ids exported from another system. Turn the list into a derived table or an IN clause and join it to your own data, as below; the Mutqan VALUES and IN-clause generators produce the list syntax from pasted values.
- One check per rule, named after the rule — for example orders_total_matches_items.
- Zero rows means pass; any rows are the evidence you attach to the bug report together with the query.
- Read-only: a check never modifies data.
- Bounded: filter by date or batch so the check stays fast on large tables.
- Versioned beside the migrations and reviewed like code.
-- Ids exported from the invoicing service that the order database does not have.
-- PostgreSQL and SQLite syntax; MySQL 8.0.19+ writes VALUES ROW(1042), ROW(1043), ...
WITH exported (order_id) AS (
VALUES (1042), (1043), (1044), (1051)
)
SELECT e.order_id
FROM exported e
LEFT JOIN orders o ON o.order_id = e.order_id
WHERE o.order_id IS NULL;Try it: SQL VALUES Generator Try it: SQL IN Clause Generator Try it: CSV to SQL Try it: JSON to SQL
Frequently asked questions
How much SQL does a tester need?
SELECT with WHERE, inner and left joins, GROUP BY with HAVING, COUNT, SUM and COALESCE, subqueries with EXISTS, and BEGIN/COMMIT/ROLLBACK. That covers nearly every verification query on this page. Window functions and query tuning are useful later, not first.
Why use LEFT JOIN … IS NULL instead of NOT IN?
Because NOT IN returns no rows at all when its subquery contains a NULL, so an orphan check written with NOT IN can pass while orphans exist. LEFT JOIN … IS NULL and NOT EXISTS behave correctly with NULLs.
Can I run verification queries on production?
Only when your role allows it, with a read-only account, bounded by keys or dates, outside peak load, and without exporting personal data. Many teams run a small set of aggregate integrity checks in production and keep row-level investigation for test environments.
Why does my count differ from the report?
Usually because the two use different filters: time zone of the day boundary, inclusive versus half-open date ranges, soft-deleted or cancelled rows, test accounts, or a report built from a replica or warehouse that lags behind. Write down each side’s definition before calling it a defect.
Should I use SELECT * in checks?
Not in saved checks. List the columns you are verifying so the output is readable in a bug report and the check does not change when someone adds a column. SELECT * is fine for a quick look at one row.
How do these queries fit into automated tests?
The test calls the API, then runs the matching query through the test framework’s database connection and asserts on the result — the exact row for a single change, zero rows for an integrity rule. Run them against a freshly seeded database in CI so the expected values are known.
References
- PostgreSQL Documentation: SQL SyntaxLexical structure of SQL in PostgreSQL: identifiers, quoting, string constants and escaping.
- SQL As Understood By SQLiteThe SQL grammar accepted by SQLite, statement by statement, with railroad diagrams.
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- Constraints — PostgreSQL DocumentationCheck, not-null, unique, primary-key, foreign-key and exclusion constraints — the rules a database enforces and a tester verifies.
- Transactions — PostgreSQL DocumentationWhat a transaction is: several steps applied all-or-nothing with BEGIN, COMMIT and ROLLBACK, invisible to others until committed.
- OWASP Cheat Sheet Series: SQL Injection PreventionParameterised queries, stored procedures used safely, allow-list validation and least privilege — the defences a database test should confirm.
Practice exercise
Verify a migration rehearsal with SQL checks
You test a fictional online home-appliance store that is moving its order data from an old platform to a new one. In yesterday’s first migration rehearsal, the migration tool loaded one batch into the shared staging database — orders 5001 to 5008 of the old platform with their customers, items and payments — with foreign-key checks switched off for the bulk load. Before the second rehearsal, the team wants a data check suite that runs after every load. Your colleague Hamza has drafted five checks and proposed a clean-up. The material lists what differs from the guide’s example schema, the old platform’s report, the loaded rows, the draft and the clean-up.
Work on paper with the guide’s patterns. The batch is small on purpose, so you can run every query by hand against the rows shown. Write each finding so it could go into a defect report together with its query and the rows it returned. A timezone converter helps with one of the checks. Allow 45–60 minutes.
Fictional online home-appliance store - order data migration to a new platform
Rehearsal 1 ran yesterday on staging, which the whole QA team shares. The migration
tool loaded one batch: orders 5001 to 5008 of the old platform with their customers,
items and payments, and the unmatched payments of the same period. Foreign-key
checks were switched off for the bulk load. QA accounts on staging can read and write.
New platform: PostgreSQL, the guide's example schema and rules R1 to R3, and also:
- orders.status allows only PENDING, PAID, SHIPPED, CANCELLED (CHECK constraint)
- payments.order_id may be NULL: a payment the gateway confirmed that the shop
could not match to an order ("unmatched"); finance matches these by hand
- R4: one customer per email address; emails that differ only in letter case
or surrounding spaces belong to the same customer
- timestamps are stored in UTC
Old platform: reports use store time, Asia/Riyadh (UTC+3, no daylight saving).
The order total includes the delivery fee when one was charged; the fee has no
item line.
Old platform report "Orders by status", September 2026, store time
status order_count amount_sum
CANCELLED 1 45.00
PAID 3 510.00
REFUNDED 1 75.00
SHIPPED 2 449.00
Total: 7 orders, 1,079.00
Rows after rehearsal 1 (only the columns the checks use; times in UTC)
customers
customer_id email full_name
101 'layla.h@example.com' 'Layla Hassan'
102 'omar.k@example.com' 'Omar Khalil'
103 'Layla.H@example.com ' 'Layla Hassan'
104 'sara.n@example.com' 'Sara Nasser'
orders
order_id customer_id status total_amount created_at
5001 101 SHIPPED 250.00 2026-08-31 22:10:00
5002 102 PAID 120.00 2026-09-05 09:00:00
5003 103 PAID 310.00 2026-09-12 14:30:00
5004 104 PAID 80.00 2026-09-18 11:00:00
5005 101 CANCELLED 45.00 2026-09-22 16:45:00
5006 102 SHIPPED 199.00 2026-09-30 18:20:00
5008 104 PENDING 60.00 2026-09-30 22:40:00
order_items
order_item_id order_id product_sku quantity unit_price
1 5001 KTL-200 1 250.00
2 5002 CUP-010 4 30.00
3 5003 FAN-300 1 260.00
4 5003 FLT-020 1 40.00
5 5004 FLT-020 2 40.00
6 5005 MUG-001 3 15.00
7 5006 TST-150 1 199.00
8 5007 IRN-075 1 75.00
9 5008 CUP-010 2 30.00
payments
payment_id order_id amount status paid_at
71 5001 250.00 CAPTURED 2026-08-31 22:11:00
72 5002 120.00 CAPTURED NULL
73 5003 310.00 CAPTURED 2026-09-12 14:31:00
74 NULL 80.00 CAPTURED 2026-09-18 11:02:00
75 5005 45.00 FAILED NULL
76 5006 199.00 CAPTURED 2026-09-30 18:21:00
77 5006 199.00 CAPTURED 2026-09-30 18:21:30
78 5007 75.00 REFUNDED 2026-09-26 12:00:00
Draft check suite by Hamza (QA): each check returns zero rows when its rule holds;
Q5 returns figures to compare with the report.
-- Q1 orders_total_matches_items (R1)
SELECT o.order_id, o.total_amount,
SUM(oi.quantity * oi.unit_price) AS lines_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> SUM(oi.quantity * oi.unit_price);
-- Q2 paid_orders_have_payment (R2)
SELECT order_id, status, total_amount
FROM orders
WHERE status IN ('PAID', 'SHIPPED')
AND order_id NOT IN (SELECT order_id FROM payments WHERE status = 'CAPTURED');
-- Q3 customer_email_unique (R4)
SELECT email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- Q4 captured_payment_has_time (R3)
SELECT payment_id, order_id
FROM payments
WHERE status = 'CAPTURED' AND paid_at = NULL;
-- Q5 september_matches_old_report
SELECT status, COUNT(*) AS order_count, SUM(total_amount) AS amount_sum
FROM orders
WHERE created_at BETWEEN '2026-09-01' AND '2026-09-30'
GROUP BY status
ORDER BY status;
Hamza in the team chat: "Q1 shows order 5003 is 10.00 off, and I saw junk rows for
an order 5007 that does not exist. I'll tidy staging so the suite is green for
tomorrow's review:"
UPDATE orders SET total_amount = 300.00 WHERE order_id = 5003;
DELETE FROM order_items WHERE order_id = 5007;
DELETE FROM payments WHERE order_id = 5007;- Review Q1 to Q5 against the rule each one claims to verify. Decide whether the check is correct, can pass silently when the data is wrong, or can raise a false alarm when the data is right, and write the corrected query.
- The load ran with foreign-key checks off. Write the checks the suite still lacks for that situation, and one for an order whose payment was captured twice.
- Run the corrected and the new checks by hand on the loaded rows and write down the rows each one returns.
- Reconcile September with the old platform’s report: give the date filter you used, explain every difference, and say which difference is a defect.
- Reply to Hamza’s clean-up proposal. Then list the findings you report before the second rehearsal, each with its evidence and the question it raises.
Show the model answer
Q1 is correct. It is the guide’s R1 check: grouped by order, it keeps only the orders whose header disagrees with the sum of their lines, so an empty result is a pass. Keep it, and note what it cannot see: the inner join leaves out an order that has no items at all, which is why the suite also needs the reverse orphan check below. On the batch it returns one row: order 5003, total_amount 310.00, lines_total 300.00 (260.00 + 40.00).
Q2 passes silently. Here payments.order_id can be NULL, and the unmatched payment 74 puts a NULL into the subquery; order_id NOT IN (…, NULL) is never true, so Q2 returns zero rows whatever the data. It also checks only that a captured payment exists, while R2 requires the captured payments to add up to the total. Replace it with the guide’s R2 reconciliation: LEFT JOIN payments p ON p.order_id = o.order_id AND p.status = 'CAPTURED', WHERE o.status IN ('PAID', 'SHIPPED'), grouped by order, HAVING COALESCE(SUM(p.amount), 0) <> o.total_amount. It returns two rows: order 5004 (total 80.00, captured 0) and order 5006 (total 199.00, captured 398.00). Without COALESCE, order 5004 would vanish as well, because SUM over no rows is NULL.
Q3 passes silently: it can never return a row. The UNIQUE constraint already rejects two identical emails, and PostgreSQL compares text case-sensitively, so the variants R4 is about land in separate groups. Group by the normalised value instead: SELECT LOWER(TRIM(email)) AS email_key, COUNT(*) AS copies FROM customers GROUP BY LOWER(TRIM(email)) HAVING COUNT(*) > 1. It returns layla.h@example.com with 2 copies, and the guide’s query that lists the rows themselves shows customers 101 and 103 ('Layla.H@example.com ': two capitals and a trailing space).
Q4 passes silently: paid_at = NULL is never true. With paid_at IS NULL it returns payment 72 of order 5002; payment 75 has no time either, but it is FAILED, which R3 allows. Q4 covers only the first half of R3, so add the guide’s check for a payment recorded before its order (JOIN orders o ON o.order_id = p.order_id WHERE p.paid_at < o.created_at). It returns zero rows on this batch: every payment time is one to two minutes after its order, and the NULL time of payment 72 simply drops out of the comparison, which is why the IS NULL check is needed beside it.
Q5 raises a false alarm. BETWEEN '2026-09-01' AND '2026-09-30' ends at 2026-09-30 00:00:00, and it cuts the month in UTC while the report counts store-time days. Its result, CANCELLED 1 / 45.00 and PAID 3 / 510.00, gives 4 orders against 7 and reads as three missing orders. A half-open UTC month (>= '2026-09-01' AND < '2026-10-01') is still wrong for this comparison: it leaves out order 5001 (31 Aug 22:10 UTC is 1 Sep 01:10 store time) and takes in order 5008 (30 Sep 22:40 UTC is 1 Oct 01:40 store time). Write the store-time month in UTC: created_at >= '2026-08-31 21:00:00' AND created_at < '2026-09-30 21:00:00'. Result: CANCELLED 1 / 45.00, PAID 3 / 510.00, SHIPPED 2 / 449.00, that is 6 orders and 1,004.00. Every status matches the report except REFUNDED 1 / 75.00, which has no row in the new database: one order is missing. This check returns figures rather than violations, so save it with the report’s expected figures next to it.
Checks to add because the load ran without foreign keys, each named after its rule and read-only like the others. items_have_order (LEFT JOIN orders o ON o.order_id = oi.order_id WHERE o.order_id IS NULL) returns item 8, whose order 5007 does not exist. payments_have_order written as in the guide returns payment 78 (order 5007) and also payment 74, because a NULL order_id never matches a parent. Unmatched payments are allowed by design, so add AND p.order_id IS NOT NULL; it then returns payment 78 only, and a separate list for finance (WHERE order_id IS NULL) shows payment 74. orders_have_items, written with NOT EXISTS, returns zero rows: a pass. payment_captured_once (WHERE status = 'CAPTURED' AND order_id IS NOT NULL GROUP BY order_id HAVING COUNT(*) > 1) returns order 5006 with 2 captured payments and 398.00. The IS NOT NULL is needed here too: GROUP BY puts all NULLs into one group, so two unmatched payments would look like a double charge. When the full data is loaded, bound each check by batch so it stays fast.
The reconciliation and the orphan checks tell one story: order 5007 is missing, while its item 8 and payment 78 were loaded without it. The material points to the cause: 5007 is REFUNDED on the old platform, but the new orders.status CHECK allows only PENDING, PAID, SHIPPED and CANCELLED, so the order row was most likely rejected while its children loaded because foreign-key checks were off. Confirm it in the migration tool’s log. This is the most serious finding — an order lost in the migration — and it needs a decision from the product owner on how refunded orders migrate, a fix in the migration and the same checks after the next rehearsal.
Hamza’s clean-up must not run. The total of order 5003 is not a migration error: the old report’s PAID figure of 510.00 adds up only with 310.00 for this order, payment 73 shows that the customer paid 310.00, and the old platform’s totals include the delivery fee, which has no item line. Setting the total to 300.00 would erase the amount actually charged, make the R2 check fail for 5003 instead and break the September reconciliation (PAID 500.00 against 510.00). Deleting the rows of order 5007 destroys the evidence that an order was lost, and the next load would recreate them, because the defects are in the migration, not in staging. Staging is shared: no UPDATE or DELETE runs there without the agreement of its owner. If a data change is ever agreed, run its WHERE clause as SELECT COUNT(*) first (1 row for each statement here), run the change inside BEGIN … ROLLBACK and compare the rows-affected count before a COMMIT, and keep the exact statements in the ticket. Ask also for a read-only account for verification, since the QA accounts can write.
Findings to report before the second rehearsal, each with its query and the rows returned. (1) Order 5007 not migrated; item 8 and payment 78 are orphans; probable cause: the REFUNDED status. (2) Order 5003: total 310.00 against lines of 300.00; the 10.00 is most likely the delivery fee, which has no place in the new model; ask the product owner whether it becomes an item line or a separate fee column, and update R1 to match. (3) Order 5004 is PAID without a captured payment, while the unmatched payment 74 has the same amount, 80.00, two minutes after the order; check on the old platform whether 74 belonged to 5004: if it did, the migration lost the link; if not, the source data is wrong and finance decides. (4) Order 5006 has two captures of 199.00, 30 seconds apart; compare with the old platform’s payments and the gateway: if both captures are there, the customer really was charged twice — a refund for finance and a missing idempotency check to report there; if only one is, the migration created the extra row. (5) Customers 101 and 103 are the same person under R4, with the orders split between two records; ask whether the old platform had one account or two, and agree the merge rule before production. (6) Payment 72 is captured without paid_at; compare with the old platform to see whether the mapping lost the time. The checks that returned zero rows — orders without items, payments before their order — are recorded as passes.
If your queries look different, check them against the rule rather than the wording: NOT EXISTS and LEFT JOIN … IS NULL are equivalent for the orphan checks, and if the shop allowed split payments you would drop the double-capture check and rely on R2, which compares sums. What matters is that each check returns zero rows on correct data, cannot pass silently because of a NULL, compares like with like (the same time zone, a half-open range), and that every finding goes out with its query and the rows it returned.
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.
Part of the QA Roadmap
Before this topic
Next topics
Tools mentioned in this guide
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Turn a list of values (one per line or comma separated) into a quoted SQL IN (...) clause.
Convert tabular data into a SQL VALUES list or a VALUES-based derived table.
Convert CSV data into CREATE TABLE and INSERT statements with inferred column types.
Convert a JSON array of objects into SQL INSERT statements (with optional CREATE TABLE).