{"id":"l-sql-4-1","courseId":"sql-for-analysts","lang":"en","kind":"lesson","summary":"Write data-quality checks as queries that return the offending rows, bundle them into a reusable suite, and investigate each failure as a question for the business rather than a bug to hide.","html":"<p>Karim is presenting 2025 results to the board next week. His request is short and serious: “Before these numbers go on a slide, how sure are we about the data?” Every query you’ve written assumes the data means what the documentation says. This lesson is about checking that assumption systematically, so that “I’m confident” is backed by queries, not feelings.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">A check is a query that returns the bad rows</h2>\n<p>The most useful habit in data quality is to phrase each rule as a query that returns the rows <strong>breaking</strong> it. An empty result means the rule holds; anything else is evidence you can show someone. Here’s the rule “a product sits in a sub-category, never directly in a top-level one”, from the data documentation:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"0\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">product_id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">c</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">name</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> category</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> products </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> p</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">JOIN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> categories </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> c </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ON</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> c</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">category_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">category_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> c</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">parent_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> IS</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> NULL</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p>Two rows: the Picnic Blanket filed directly under Outdoor and the Bamboo Cutting Board under Kitchen. That explains the odd “Kitchen” and “Outdoor” rows in the category revenue table from Section 2. A check that returns rows you can name is far more persuasive than a count.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">The five families of checks</h2>\n<p>Most rules fall into five families. Running at least one check from each catches the large majority of problems:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>Family</th>\n<th>Question</th>\n<th>Cartwheel example</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Uniqueness</td>\n<td>Is the key really unique?</td>\n<td>Duplicate emails, ignoring case</td>\n</tr>\n<tr>\n<td>Completeness</td>\n<td>Are values present where they must be?</td>\n<td>Tickets with no <code dir=\"ltr\">agent_id</code></td>\n</tr>\n<tr>\n<td>Validity</td>\n<td>Are values in their allowed range?</td>\n<td>Discount between 0 and 0.25</td>\n</tr>\n<tr>\n<td>Referential integrity</td>\n<td>Does every reference point to a real row?</td>\n<td>Order items whose order doesn’t exist</td>\n</tr>\n<tr>\n<td>Consistency</td>\n<td>Do related columns and tables tell the same story?</td>\n<td>Orders marked ‘returned’ with no return record; open tickets that already have a rating</td>\n</tr>\n</tbody>\n</table></div><p>Database constraints (<code dir=\"ltr\">PRIMARY KEY</code>, <code dir=\"ltr\">NOT NULL</code>, <code dir=\"ltr\">CHECK</code>, <code dir=\"ltr\">FOREIGN KEY</code>) enforce some of these at write time, if they were declared and, in SQLite’s case, if foreign-key enforcement was switched on. Analysts rarely control that, and consistency rules across tables usually can’t be expressed as constraints at all. So you check.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">A suite you can rerun</h2>\n<p>One check is a query; a dozen checks are a suite. Give each a name, count its failures, and stack them with <code dir=\"ltr\">UNION ALL</code>:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"1\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'customers: duplicate email (any case)'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> check_name, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> failures</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> LOWER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(email) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> customers </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> LOWER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(email) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">HAVING</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">></span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">UNION ALL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'order_items: no matching order'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> oi</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> NOT</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> EXISTS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> o </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">UNION ALL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'order_items: discount outside 0-0.25'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> discount </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">NOT</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> BETWEEN</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">25</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">UNION ALL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'products: filed under a top-level category'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> products </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> p</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">JOIN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> categories </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> c </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ON</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> c</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">category_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">category_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> c</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">parent_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> IS</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> NULL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">UNION ALL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'orders: returned but no return record'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> o</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">status</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'returned'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> NOT</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> EXISTS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    SELECT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> oi</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    JOIN</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> returns</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> r </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ON</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> r</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_item_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_item_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">UNION ALL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'tickets: rated while still open'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> support_tickets</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> closed_at </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">IS</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> NULL</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> AND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> satisfaction </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">IS NOT NULL</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"1\" hidden></div></div>\n<p>Three checks fail. Each compound part must return the same number of columns; the first SELECT names them. <code dir=\"ltr\">UNION ALL</code> keeps every row, whereas <code dir=\"ltr\">UNION</code> would deduplicate, which costs time and could merge two checks that happen to share a name and count. Save the suite next to your analysis and run it every time the data is refreshed; a check that passed in October can fail in December.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Failures are questions, not verdicts</h2>\n<p>Now read the failures like an analyst, not a linter:</p>\n<ul>\n<li><strong>Two returned orders without return records</strong> were both placed in the last two weeks of December 2025. That smells like a process delay (the return is logged when the parcel arrives back), not corruption. Ask operations.</li>\n<li><strong>Three open tickets with a satisfaction score</strong> were opened in the last days of December. Perhaps the survey goes out before the ticket is formally closed. Ask Julia.</li>\n<li><strong>Two misfiled products</strong> are almost certainly a catalogue error. Report it to whoever maintains the catalogue, and in the meantime decide how your category report treats them.</li>\n</ul>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Filtering problems away silently</span></p>\n<p>The tempting move is to add <code dir=\"ltr\">AND status &lt;&gt; 'returned'</code> or <code dir=\"ltr\">WHERE parent_id IS NOT NULL</code> until the numbers look clean. Now your result disagrees with everyone else’s, and nobody knows why. Keep the rows, note the issue and your treatment in the report (“two products are counted under their parent category pending a catalogue fix”), and let the owner fix the source.</p>\n</aside>\n<p>There’s one more failure hiding in this data, and it’s the most interesting one: products that appear in orders <em>before</em> their recorded launch date. Finding it is your exercise.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Reconcile against a number someone else owns</span></p>\n<p>The strongest check compares your result with a figure from outside your query: finance’s booked revenue, the payment provider’s total, last month’s published report. If your 2025 revenue differs from finance’s by 0.1%, you can explain it. If it differs by 10%, you’ve found a definition mismatch before the board did.</p>\n</aside>\n<p>Next you’ll look at the other side of trust, performance: why some of these queries are instant and others crawl, and how to see what the database is actually doing.</p>\n","toc":[{"id":"sec-1","text":"A check is a query that returns the bad rows","level":2},{"id":"sec-2","text":"The five families of checks","level":2},{"id":"sec-3","text":"A suite you can rerun","level":2},{"id":"sec-4","text":"Failures are questions, not verdicts","level":2}],"takeaways":["Phrase every data-quality check as a query that returns the rows breaking a rule, so zero rows means the check passes.","Cover uniqueness, completeness, valid ranges, referential integrity and cross-table consistency; each catches a different kind of problem.","Bundle checks into one UNION ALL query with a name and a failure count per check, and run it before any important analysis.","A failing check is a question to take to the data’s owner, not something to filter away silently; record what you found and what you decided."],"further":[{"title":"SQLite compound SELECT statements (UNION ALL)","url":"https://www.sqlite.org/lang_select.html#compound_select_statements"},{"title":"SQLite CREATE TABLE constraints","url":"https://www.sqlite.org/lang_createtable.html#constraints"},{"title":"PostgreSQL constraints","url":"https://www.postgresql.org/docs/current/ddl-constraints.html"}],"quiz":[{"q":"<p>Which query is the best data-quality check for “every order item belongs to an existing order”?</p>","options":[{"text":"<code dir=\"ltr\">SELECT COUNT(*) FROM order_items JOIN orders USING (order_id)</code>","why":"An inner join silently drops orphans, so the count looks fine whether or not orphans exist."},{"text":"<code dir=\"ltr\">SELECT * FROM order_items oi WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.order_id = oi.order_id)</code>","why":"Correct. It returns exactly the rows that break the rule, so an empty result means the check passes and any row is evidence."},{"text":"<code dir=\"ltr\">SELECT COUNT(DISTINCT order_id) FROM order_items</code>","why":"This counts orders referenced by items but can’t tell whether those orders exist."},{"text":"<code dir=\"ltr\">SELECT * FROM orders WHERE order_id IS NOT NULL</code>","why":"This checks the wrong table and the wrong rule; primary keys are never NULL anyway."}],"answer":1},{"q":"<p>Two orders have status ‘returned’ but no row in <code dir=\"ltr\">returns</code>. Both were placed in the last two weeks of December 2025. What’s the most sensible next step?</p>","options":[{"text":"Delete the two orders from your analysis so the numbers are clean.","why":"Removing rows hides the issue and changes revenue. Analysts report and query data; they don’t quietly rewrite it."},{"text":"Change their status to ‘delivered’ in your query with a CASE expression.","why":"That invents a fact. The status may be right and the return record simply not created yet."},{"text":"Note them, ask the operations team whether returns are recorded with a delay, and say in your report how you treated them.","why":"Correct. The timing suggests a process lag rather than an error. Only the data’s owner can confirm, and your report should state the assumption."}],"answer":2},{"q":"<p>Why use <code dir=\"ltr\">UNION ALL</code> rather than <code dir=\"ltr\">UNION</code> to combine check results into one suite?</p>","options":[{"text":"UNION removes duplicate rows, which could merge two checks that happen to have the same name and count, and it does extra work to deduplicate.","why":"Correct. Each check’s row is meant to appear exactly once; UNION ALL keeps every row and skips the deduplication step."},{"text":"UNION can’t combine queries that use aggregates.","why":"It can; both operators accept any SELECTs with matching column counts."},{"text":"UNION ALL sorts the result by check name.","why":"Neither operator promises an order. Add ORDER BY if you want one."}],"answer":0},{"q":"<p>Three discontinued products (<code dir=\"ltr\">is_active = 0</code>) still have orders in December 2025. What kind of problem is this?</p>","options":[{"text":"A uniqueness problem.","why":"No key is duplicated here; the rows are distinct."},{"text":"A referential-integrity problem.","why":"The products exist, so every order item still points to a real product."},{"text":"A range-validity problem.","why":"The <code dir=\"ltr\">is_active</code> flag holds a valid 0 or 1; the value itself is not out of range."},{"text":"A consistency question between tables, which may be a data error or a legitimate business case such as clearing stock.","why":"Correct. The flag and the sales disagree. Whether that’s wrong depends on what “discontinued” means at Cartwheel, which is a question for the product team."}],"answer":3}],"exercise":{"kind":"sql","starter":"-- Data-quality check: products sold before their launch date\n-- Write it from scratch. One row per offending product with:\n--   product_id, name, launched_on, first_sold (YYYY-MM-DD), lines\nSELECT product_id, name, launched_on\nFROM products;","solution":"SELECT\n  p.product_id,\n  p.name,\n  p.launched_on,\n  date(MIN(o.order_date)) AS first_sold,\n  COUNT(*) AS lines\nFROM order_items AS oi\nJOIN orders AS o ON o.order_id = oi.order_id\nJOIN products AS p ON p.product_id = oi.product_id\nWHERE o.order_date < p.launched_on\nGROUP BY p.product_id, p.name, p.launched_on;","orderMatters":false,"title":"Sold before launch","prompt":"<p>Write this check <strong>from scratch</strong>. Rule: a product can’t be ordered before its <code dir=\"ltr\">launched_on</code> date.</p>\n<p>Return one row per product that breaks the rule, with <code dir=\"ltr\">product_id</code>, <code dir=\"ltr\">name</code>, <code dir=\"ltr\">launched_on</code>, <code dir=\"ltr\">first_sold</code> (the date of its earliest order, as ‘YYYY-MM-DD’) and <code dir=\"ltr\">lines</code> (how many order lines were placed before the launch date). Only count lines from before launch. Row order doesn’t matter.</p>\n","hints":["You need three tables: <code dir=\"ltr\">order_items</code> (the lines), <code dir=\"ltr\">orders</code> (the date) and <code dir=\"ltr\">products</code> (the launch date).","The rule-breaking condition compares two columns from different tables: <code dir=\"ltr\">o.order_date &lt; p.launched_on</code>. ISO text compares correctly.","Group by the product columns, then use <code dir=\"ltr\">date(MIN(o.order_date))</code> and <code dir=\"ltr\">COUNT(*)</code>."],"explanation":"<p>Joining lines to their orders and products puts the order date and the launch date on the same row, so the rule becomes a WHERE condition. Grouping by product turns 149 offending lines into 8 products. Some gaps are huge: the Adjustable Dumbbells were selling from February 2024 but ‘launched’ in May 2025. That pattern suggests <code dir=\"ltr\">launched_on</code> records something else, perhaps a relaunch or a catalogue import date, rather than true first availability. Take that question to the product team before using <code dir=\"ltr\">launched_on</code> in any ‘new product’ analysis.</p>\n","checkLabels":{}},"words":720,"readMinutes":3}