<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: GenerateRealData Team</title>
    <description>The latest articles on DEV Community by GenerateRealData Team (@grd_dev).</description>
    <link>https://dev.to/grd_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4208893%2F3c8be4e0-a065-4696-aa2d-71e756ffe136.png</url>
      <title>DEV Community: GenerateRealData Team</title>
      <link>https://dev.to/grd_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grd_dev"/>
    <language>en</language>
    <item>
      <title>377 SQL checks on 4.6 million generated rows: what they caught</title>
      <dc:creator>GenerateRealData Team</dc:creator>
      <pubDate>Thu, 05 Nov 2026 14:40:21 +0000</pubDate>
      <link>https://dev.to/generaterealdata/377-sql-checks-on-46-million-generated-rows-what-they-caught-5gkc</link>
      <guid>https://dev.to/generaterealdata/377-sql-checks-on-46-million-generated-rows-what-they-caught-5gkc</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/generaterealdata"&gt;part 1&lt;/a&gt; I showed the method: write every rule about your data as a sentence, then one SQL query that counts the rows breaking it. The rule holds when the query returns 0.&lt;/p&gt;

&lt;p&gt;I promised to run it at scale. So I did: &lt;strong&gt;377 checks across 13 generated databases, 4,594,497 rows in total.&lt;/strong&gt; Today every one of those queries returns 0. Getting there took several fixes, and this post covers the ones I'd never have found by looking at the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Thirteen databases, each generated from a schema plus rules, each with a &lt;code&gt;checks.sql&lt;/code&gt; file holding one query per rule:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dataset&lt;/th&gt;
&lt;th&gt;Tables&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E-commerce database&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;499,385&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto insurance policies and claims&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;192,843&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hospital records&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;300,000&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SaaS subscriptions and churn&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;249,113&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HR, workforce and attrition&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;155,186&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App users with edge cases&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fleet GPS tracking&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;404,052&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card transactions with fraud labels&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI jobs and salaries&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;510,000&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web analytics funnel events&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;425,918&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Black Friday and Cyber Week sales&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;408,000&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smart meter readings&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dirty e-commerce data&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;250,000&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rules are the same kinds as in part 1: keys, arithmetic inside a row, arithmetic across tables, "only when" existence rules, time order, and a few rules about the shape of a whole table. A runner executes the file and prints one line per rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python verify.py ecommerce ecommerce.sqlite
...
48 of 48 rules hold.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 13 databases, their check files and the runner are public, so you don't have to take my word for any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the checks caught
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Half the cancellations happened on the last day
&lt;/h3&gt;

&lt;p&gt;The SaaS database covers a period ending 30 September. The first full run passed every per-row rule: no subscription ended before it started or after 30 September, and every cancelled subscription had an end date.&lt;/p&gt;

&lt;p&gt;Then I counted cancellations per day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ended_on&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cancellations&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;cancellations&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1,718 of the 3,015 cancellations, 57%, were dated 30 September.&lt;/strong&gt; The HR database had the same problem: 1,242 of 2,998 leavers, 41%, left on the last day.&lt;/p&gt;

&lt;p&gt;The cause was a rule doing exactly what it said. End dates were drawn after the start date, then capped at the end of the period. Every date that would have fallen after 30 September was moved &lt;em&gt;onto&lt;/em&gt; 30 September. Each row was valid. The table as a whole was nonsense, and any churn chart built on it would show a cliff on the final day.&lt;/p&gt;

&lt;p&gt;The fix was to draw end dates inside the period instead of capping them afterwards. The lesson for the check file: &lt;strong&gt;per-row rules can't see a pile-up.&lt;/strong&gt; Add one "shape" check for any date that's computed from another date: the busiest day shouldn't hold a big share of the rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A shop with one product
&lt;/h3&gt;

&lt;p&gt;The engine sizes tables relative to each other: ask for a large dataset and the products table gets hundreds of rows. Ask for a small one, as a quick demo does at 100 rows, and the same proportions gave the shop &lt;strong&gt;one product&lt;/strong&gt;. Every order bought the same thing.&lt;/p&gt;

&lt;p&gt;Nothing was broken in the per-row sense. Keys resolved and totals added up. I only noticed when I looked at small samples, and that's the point: a check of the form "the products table has at least N rows" would have caught it on the first run. The fix was a minimum row count per table, so small runs keep a believable catalogue.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A column whose name started with "check"
&lt;/h3&gt;

&lt;p&gt;Not every catch was in the data. While building a health dataset, the schema reader met a column called &lt;code&gt;checkup_id&lt;/code&gt; and treated the line as a &lt;code&gt;CHECK&lt;/code&gt; constraint, because it starts with the word &lt;code&gt;check&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No query on the data would explain that, but the habit is the same one: write the rule down and test it. "Every declared column reaches the generated table" is now a test that runs with every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consistency isn't realism
&lt;/h2&gt;

&lt;p&gt;377 zeros prove the data is &lt;em&gt;consistent&lt;/em&gt;. They don't prove it's &lt;em&gt;interesting&lt;/em&gt;. A table where every customer returns exactly 21% of orders would pass every check and teach nothing.&lt;/p&gt;

&lt;p&gt;So each dataset also gets a realism test that fits its purpose, and the number goes in its data card:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Black Friday sales:&lt;/strong&gt; a gradient-boosting model predicts which purchases are returned with ROC AUC 0.74 on held-out rows. Learnable, not trivial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI jobs and salaries:&lt;/strong&gt; a gradient-boosting salary model reaches R² 0.86 on held-out rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And each card has a &lt;em&gt;Good to know&lt;/em&gt; section listing what the data does &lt;strong&gt;not&lt;/strong&gt; model. For example, in the Black Friday set &lt;code&gt;customer_id&lt;/code&gt; isn't a consistent customer, so per-customer features won't work. Writing those down is part of the method too: a limit you state can't mislead anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the data was made
&lt;/h2&gt;

&lt;p&gt;I build the tool that generated these datasets, &lt;a href="https://www.generaterealdata.com/relational-data-generator/?ref=devto" rel="noopener noreferrer"&gt;GenerateRealData&lt;/a&gt;, so here's what it does, briefly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Relational by design.&lt;/strong&gt; It generates whole multi-table databases in dependency order, so every foreign key points at a row that exists, and totals, balances and dates agree across tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules in plain terms.&lt;/strong&gt; "A cancelled order has no shipment" or "discharge comes after admission" are kept on every row, at any size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A large type library.&lt;/strong&gt; 232 data types and 222 built-in generators, one of the largest libraries of any test data generator: real airport, ICD-10 and MCC codes, VINs, IBANs and card numbers with valid check digits, real cities with matching postcodes and time zones. Over 200 automatic links keep fields in a row consistent: the city sits in the row's country, the email follows the name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale.&lt;/strong&gt; Up to 500,000 rows in one run. The AI jobs dataset, 510,000 rows, came from a single API request in about three minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your schema or a description.&lt;/strong&gt; Upload &lt;code&gt;CREATE TABLE&lt;/code&gt; statements for PostgreSQL, MySQL, SQL Server or SQLite, or describe the database in plain English. Download as a SQL script, CSV, Excel or JSON.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checks in this post are how I hold it to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the checks yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every dataset with its &lt;code&gt;checks.sql&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/generaterealdata/synthetic-datasets" rel="noopener noreferrer"&gt;github.com/generaterealdata/synthetic-datasets&lt;/a&gt;. Download a database from its Kaggle link and run &lt;code&gt;python verify.py &amp;lt;dataset&amp;gt; &amp;lt;file&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full data:&lt;/strong&gt; all 13 are free on &lt;a href="https://www.kaggle.com/generaterealdata/datasets" rel="noopener noreferrer"&gt;Kaggle&lt;/a&gt; under CC BY 4.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The challenge from part 1 is still open:&lt;/strong&gt; the &lt;a href="https://www.kaggle.com/datasets/generaterealdata/dirty-ecommerce-data-for-cleaning-practice" rel="noopener noreferrer"&gt;dirty e-commerce dataset&lt;/a&gt; has 11,577 planted defects and an answer key. Write checks, run them, and post your score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; which shape check would you add to your own data? A last-day pile-up was mine. Tell me yours in the comments. Next in the series I'll collect readers' rules, the ones no constraint could catch, with a query for each.&lt;/p&gt;




</description>
      <category>sql</category>
      <category>database</category>
      <category>testing</category>
      <category>datascience</category>
    </item>
    <item>
      <title>It passed every constraint. It was still wrong.</title>
      <dc:creator>GenerateRealData Team</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:58:15 +0000</pubDate>
      <link>https://dev.to/generaterealdata/it-passed-every-constraint-it-was-still-wrong-187f</link>
      <guid>https://dev.to/generaterealdata/it-passed-every-constraint-it-was-still-wrong-187f</guid>
      <description>&lt;p&gt;My first generated test database loaded without a single error. Every foreign key resolved. Row counts matched. The first twenty rows of every table looked right: sensible names, dates and prices.&lt;/p&gt;

&lt;p&gt;Then I asked it one question: &lt;em&gt;how many cancelled orders have a shipment?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer was not zero.&lt;/p&gt;

&lt;p&gt;No constraint had complained, because no constraint could. This post is the method I've used since to prove a multi-table dataset is consistent. It needs nothing but SQL, and it works on any data: generated, sampled from production, or produced by a pipeline you're testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick quiz before you scroll
&lt;/h2&gt;

&lt;p&gt;Which of these can a &lt;code&gt;FOREIGN KEY&lt;/code&gt; or &lt;code&gt;CHECK&lt;/code&gt; constraint catch, without triggers?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An order item points at an order that doesn't exist.&lt;/li&gt;
&lt;li&gt;A line total isn't quantity × unit price.&lt;/li&gt;
&lt;li&gt;An order's subtotal isn't the sum of its line totals.&lt;/li&gt;
&lt;li&gt;A cancelled order has a shipment.&lt;/li&gt;
&lt;li&gt;An order is dated before its customer signed up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep your answer in mind. It's further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method: every rule is a query that counts violations
&lt;/h2&gt;

&lt;p&gt;Write each rule as a sentence. Then write one query that returns &lt;strong&gt;the number of rows breaking it&lt;/strong&gt;. The rule holds when the query returns 0.&lt;/p&gt;

&lt;p&gt;That's the whole idea, and it gives you three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A number, not an opinion.&lt;/strong&gt; "Looks fine" becomes &lt;code&gt;0&lt;/code&gt; or &lt;code&gt;4,317&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pointer to the problem.&lt;/strong&gt; Change &lt;code&gt;COUNT(*)&lt;/code&gt; to &lt;code&gt;*&lt;/code&gt; and you're looking at the offending rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation.&lt;/strong&gt; The list of rules says what the data promises, and anyone can check it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here it is on a seven-table shop database: customers, products, orders, order items, payments, shipments and returns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: keys
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- every order_items.order_id exists in orders&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your database may enforce this already. Check anyway: test data is often loaded with constraints switched off, or into a warehouse that doesn't enforce them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: arithmetic inside a row
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- line_total = quantity x unit_price&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unit_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;011&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tolerance is deliberate. Comparing rounded money with &lt;code&gt;=&lt;/code&gt; reports false failures of one cent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: arithmetic across tables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- an order's subtotal = the sum of its line totals&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
      &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;011&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One level up, the payment equals the order total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the amount paid = the order total&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;011&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer 4: existence rules (the ones that bit me)
&lt;/h3&gt;

&lt;p&gt;"Only when" and "exactly one" each need &lt;strong&gt;two&lt;/strong&gt; queries, one per direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- shipments exist only for shipped, delivered or returned orders&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'returned'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- and every such order has exactly one shipment&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'returned'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first finds shipments that shouldn't exist. The second finds orders missing one, or with two. A dataset can pass either query and fail the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 5: time
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- an order is never placed before its customer signed up&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signup_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check the &lt;strong&gt;edges of the period&lt;/strong&gt;. If the data should end on 30 September, count the rows dated after it. In generated data this fails more often than anything else: a date built as "parent date plus a few days" doesn't know where the period ends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 6: rules about the whole table
&lt;/h3&gt;

&lt;p&gt;Some rules describe a shape, not a row: "about 1.5% of transactions are fraud", "orders peak in November". They still fit the pattern by returning 0 or 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the fraud rate is between 1% and 2%&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_fraud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quiz answer
&lt;/h2&gt;

&lt;p&gt;Only &lt;strong&gt;1&lt;/strong&gt; (a foreign key) and &lt;strong&gt;2&lt;/strong&gt; (a &lt;code&gt;CHECK&lt;/code&gt; on the row) can be enforced by constraints. Numbers 3, 4 and 5 compare rows across tables, and plain constraints can't express that. Those are the rules that break dashboards, fail tests that aren't the code's fault, and make the numbers not add up in a demo.&lt;/p&gt;

&lt;p&gt;How many did you get right? Tell me in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running them: a 16-line runner
&lt;/h2&gt;

&lt;p&gt;Keep every check in one &lt;code&gt;checks.sql&lt;/code&gt; file: a &lt;code&gt;--&lt;/code&gt; comment with the rule, then its query. This runs them all against SQLite and exits with an error if any rule fails, so it can run in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok  &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FAIL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;python run_checks.py shop.db checks.sql&lt;/code&gt; prints something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ok           0  every order_items.order_id exists in orders
FAIL         1  line_total = quantity x unit_price
ok           0  an order's subtotal = the sum of its line totals
FAIL         1  shipments exist only for shipped, delivered or returned orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For PostgreSQL, swap &lt;code&gt;sqlite3&lt;/code&gt; for &lt;code&gt;psycopg&lt;/code&gt; (version 3) and change the connect line. The queries run as they are.&lt;/p&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Index the key columns first&lt;/strong&gt; on big tables. The "exactly one" queries crawl without them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with five rules, not fifty.&lt;/strong&gt; Keys, one arithmetic rule, one existence rule, one time rule and the period edges find most problems.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the checks found in my own data
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://www.generaterealdata.com/?ref=devto" rel="noopener noreferrer"&gt;GenerateRealData&lt;/a&gt;, a tool that generates related test databases from a description or a SQL schema. So I badly wanted these numbers to be zero. On the first full-size runs, they weren't. Every one of these looked fine by eye and was caught only by a query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A products table with 360 rows where 1,500 were planned.&lt;/strong&gt; A unique name column ran out of distinct values, and the table was silently capped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A table of eight pricing plans that came out with one row.&lt;/strong&gt; Every subscription pointed at the same plan. The check that found it was the simplest in the file: count the rows and compare with eight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orders dated after the period ended&lt;/strong&gt;, pushed there by a rule that moved a date to follow its parent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate emails&lt;/strong&gt; in a column declared unique, in a dataset big enough for random names to collide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each became a fix, and the checks are how I know the fixes held. Today the shop database passes all 48 of its rules:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7olao86goshj76c1rv4w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7olao86goshj76c1rv4w.png" alt="E-commerce database: 48 checks, 0 mistakes" width="800" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The same queries measure dirt
&lt;/h2&gt;

&lt;p&gt;Point the checks at data you &lt;em&gt;know&lt;/em&gt; is broken and they tell you how broken. I made a shop dataset with defects planted on purpose, for cleaning practice. Running the clean version's checks against it gives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Rows breaking it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;line_total = quantity x unit_price&lt;/td&gt;
&lt;td&gt;6,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;order total = sum of its lines&lt;/td&gt;
&lt;td&gt;2,149&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;order country is the customer's country&lt;/td&gt;
&lt;td&gt;1,306&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;order date is never before the customer's signup&lt;/td&gt;
&lt;td&gt;472&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;email is unique&lt;/td&gt;
&lt;td&gt;213&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is a data-quality report, made with queries you already had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It only checks rules you thought of.&lt;/strong&gt; The cancelled-order rule exists because I got bitten. A rule nobody writes down is never tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't judge realism.&lt;/strong&gt; Data can pass every check and still be flat: the same return rate in every category, customers who reorder like clockwork. Consistency and realism are different questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It overlaps with data-testing frameworks.&lt;/strong&gt; They add scheduling, reports and alerts around the same idea. Plain queries are the version with zero setup, which is why I start there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The challenge:&lt;/strong&gt; the &lt;a href="https://www.kaggle.com/datasets/generaterealdata/dirty-ecommerce-data-for-cleaning-practice" rel="noopener noreferrer"&gt;dirty e-commerce dataset&lt;/a&gt; has &lt;strong&gt;11,577 planted defects&lt;/strong&gt; and an answer key that lists every one. Write your checks, run them, and post how many you caught.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates:&lt;/strong&gt; every dataset in &lt;a href="https://github.com/generaterealdata/synthetic-datasets" rel="noopener noreferrer"&gt;github.com/generaterealdata/synthetic-datasets&lt;/a&gt; comes with its &lt;code&gt;checks.sql&lt;/code&gt; (19 to 48 rules each). Copy one as a starting point for your own tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; what rule has bitten you that no constraint could catch? Drop it in the comments. I'll collect the best ones into a follow-up post, with the query for each.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next in this series: running hundreds of these checks on millions of generated rows, and what they caught. Follow if you want it in your feed.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>sql</category>
      <category>database</category>
      <category>testing</category>
      <category>dataengineering</category>
    </item>
  </channel>
</rss>
