SQL on a made-up pathology lab, one clause at a time
Lab data comes nested: a case holds parts, a part holds blocks, a block gives slides, and a slide can be scanned more than once. A lot of SQL mistakes happen right at those joins. This page runs six small queries over a tiny made-up lab, one clause at a time, so you can watch the rows (drawn as index cards) get copied, crossed out, piled up, and numbered. Each query comes in two versions that differ by a keyword or a line. Version A is the one often written first; version B is the fix (or, in the last query, the other way to count).
Three tables, small enough to read card by card. Open a drawer to see what is in it.
A few words used below: NULL means no value recorded. A CTE (the WITH … AS part) is a named sub-query written first. LAG reads a value from the previous row. H&E and IHC are kinds of stain; QC is quality control.
Three details are planted on purpose, because real data has them: slide 1 and slide 5 were rescanned, after a blurred scan and a scan that cut off part of the tissue; scan 7's barcode did not read, so it has no slide_id; and slide 7 has not been scanned yet, while slide 8's scan is still waiting on QC.
A database reads a query in a different order than it is written. FROM and the joins come first, then WHERE, GROUP BY, HAVING (none of the six needs it), window functions, SELECT, and ORDER BY last. Window functions are really worked out as part of SELECT, after HAVING; this page gives them their own step so you can watch them. Pick a query, pick a version, and step through. The highlighted part of the SQL is the clause doing the work.
The arrow keys step back and forward while the stepper has focus. These steps are the logical order, the order a query's meaning is defined in. A real database plans its own physical order and may filter, join, or sort in a different sequence, as long as the answer comes out the same.
COUNT(DISTINCT …) is one fix; counting each child table in its own subquery before joining is another.= on the right-hand table then throws them away, because NULL = 'pass' is not TRUE. Moving the test into ON keeps them.x NOT IN (…) is never TRUE if the list holds a NULL, so the query quietly returns nothing. NOT EXISTS does not have this problem.PARTITION BY makes LAG reach back across scanners, so a scan that follows the other scanner's work borrows its time (scans 4, 7 and 10 here).Try your own queries against the same three tables. The scratchpad runs SQLite inside this page using sql.js, SQLite compiled to WebAssembly. It is fetched from the jsDelivr CDN only when you ask for it (well under a megabyte), and nothing you type leaves the page. SQLite is one dialect among several, so a few functions are spelled differently elsewhere.
SQLite is not loaded yet.
The query box is kept in this browser between visits. The download is a plain SQL script that sets up the tables in any SQLite, with the query at the end.
This page tries something narrower: a few known mistakes, on nested lab-style data, with every row shown at every clause and the fix one click away.
ROW_NUMBER and LAG, added in version 3.25.0: SQLite window functions.NOT IN against a list with a NULL filters every row: a worked example on Microsoft Q&A, and the jOOQ manual's note on NOT IN, which suggests NOT EXISTS. Why a WHERE test can undo a LEFT JOIN: jOOQ blog, The difference between SQL's JOIN .. ON clause and the WHERE clause.The lab, its cases, patients, and scanners are made up. Every result the stepper shows was checked against SQLite running the same SQL on the same tables.