Lesson 1 of 8 · 48 min
SQL mental model: grain, order, nulls
Execution order, grain discipline, bag vs set, three-valued logic, and the narration OS interviewers grade before fancy windows.
Lesson 1 · Mental model
How SQL actually executes (and how interviewers grade you)
Syntax fluency is not the hire signal
Key idea
Logical execution order (memorize cold)
1LOGICAL PROCESSING ORDER (conceptual; dialects vary on details)23 1. FROM / JOIN build the working row set (including ON filters)4 2. WHERE filter rows (no SELECT aliases; no aggregates yet)5 3. GROUP BY collapse to groups6 4. HAVING filter groups (aggregates OK)7 5. SELECT compute expressions, aliases, DISTINCT8 6. ORDER BY sort (SELECT aliases usually OK)9 7. LIMIT / OFFSET truncate1011Windows (OVER ...) are evaluated after SELECT list inputs are known12in most mental models - after grouping, before final ORDER BY for display.13CTEs/subqueries run as nested FROM sources: each has its own ladder.1415Interview line: "WHERE cannot use SELECT aliases; HAVING can use aggregates."Common mistake
SQL runs top-to-bottom like Python, so SELECT happens first.
Grain: the non-negotiable first sentence
1-- BAD: revenue by user, but line items fan out orders2SELECT u.user_id, SUM(o.amount) AS revenue3FROM users u4JOIN orders o ON o.user_id = u.user_id5JOIN order_items i ON i.order_id = o.order_id -- multiplies order rows6GROUP BY u.user_id;7-- SUM(o.amount) is now wrong: amount repeated per line item89-- GOOD: aggregate to the grain you need, then join10WITH order_revenue AS (11 SELECT order_id, user_id, amount12 FROM orders13),14items_per_order AS (15 SELECT order_id, COUNT(*) AS n_items16 FROM order_items17 GROUP BY order_id18)19SELECT o.user_id, SUM(o.amount) AS revenue, SUM(i.n_items) AS items20FROM order_revenue o21LEFT JOIN items_per_order i ON i.order_id = o.order_id22GROUP BY o.user_id;Key idea
Sets, bags, and DISTINCT discipline
1-- COUNT variants interviewers love2SELECT3 COUNT(*) AS n_rows, -- all rows, incl. null-heavy ones4 COUNT(email) AS n_email_present, -- non-null email only5 COUNT(DISTINCT email) AS n_unique_email -- distinct non-null emails6FROM users;78-- COUNT(col) ignores NULL; COUNT(*) does not.9-- COUNT(DISTINCT col) also ignores NULL in standard SQL.NULL is not a value (three-valued logic)
1-- Three-valued logic demo2-- NULL = NULL → UNKNOWN (not TRUE)3-- NULL <> 1 → UNKNOWN4-- WHERE drops UNKNOWN the same as FALSE56SELECT * FROM t WHERE nullable_col = NULL; -- always empty7SELECT * FROM t WHERE nullable_col IS NULL; -- correct89-- NOT IN bomb: if subquery returns a NULL, NOT IN is never TRUE10SELECT *11FROM users u12WHERE u.id NOT IN (SELECT user_id FROM bans); -- dangerous if user_id can be NULL1314-- Safer anti-join patterns15SELECT u.*16FROM users u17LEFT JOIN bans b ON b.user_id = u.id18WHERE b.user_id IS NULL;1920-- or NOT EXISTS (preferred by many seniors)21SELECT u.*22FROM users u23WHERE NOT EXISTS (SELECT 1 FROM bans b WHERE b.user_id = u.id);Common mistake
NULL means zero / empty string / 'unknown but equal to other unknowns'.
Subqueries, derived tables, and correlation
1-- Correlated: users with above-average order count in their own country2SELECT u.user_id, u.country3FROM users u4WHERE (5 SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id6) > (7 SELECT AVG(cnt) FROM (8 SELECT COUNT(*) AS cnt9 FROM orders o210 JOIN users u2 ON u2.user_id = o2.user_id11 WHERE u2.country = u.country12 GROUP BY o2.user_id13 ) s14);1516-- Often clearer: pre-aggregate, then join thresholds17WITH per_user AS (18 SELECT u.user_id, u.country, COUNT(o.order_id) AS n_orders19 FROM users u20 LEFT JOIN orders o ON o.user_id = u.user_id21 GROUP BY u.user_id, u.country22),23country_avg AS (24 SELECT country, AVG(n_orders) AS avg_orders25 FROM per_user26 GROUP BY country27)28SELECT p.user_id, p.country, p.n_orders29FROM per_user p30JOIN country_avg c ON c.country = p.country31WHERE p.n_orders > c.avg_orders;Interview narration OS for SQL
- 01"How do you approach a SQL problem?" → grain → tables/keys → filters before/after agg → join risks → write → dry-run nulls/dupes.
- 02"WHERE vs HAVING?" → WHERE filters rows pre-agg; HAVING filters groups post-agg; SELECT aliases not in WHERE.
- 03"Why is my SUM too high?" → fan-out join multiplying parent measure; pre-aggregate child or sum at correct grain.
- 04"COUNT(*) vs COUNT(col)?" → COUNT(*) counts rows; COUNT(col) skips NULL in col; COUNT(DISTINCT) skips NULL too.
- 05"When DISTINCT?" → when the business wants unique keys and you already fixed grain - not as a join bandage.
- 06"ON vs WHERE on LEFT JOIN?" → ON keeps left rows with nulls; WHERE on right cols often filters them out (inner-join effect).
- 07"NULL = NULL?" → UNKNOWN; use IS NULL; prefer NOT EXISTS over NOT IN when nulls possible.
- 08"UNION or UNION ALL?" → ALL for metrics/events; UNION only when dedupe is the product requirement.
- 09"Correlated subquery OK?" → fine for clarity on small sets; rewrite to joins/CTEs when scale or interview asks optimization.
- 10"What is grain?" → the entity one result row represents; every join must preserve or intentionally change it.
If you can draw a 4-row example and mark which rows survive WHERE, which groups form, and what SUM returns, you already outrun candidates who only memorize syntax posters.
MySQL Tutorial for Beginners (full course) - use for syntax refresh onlyProgramming with MoshdocsUse The Index, Luke - SQL performance mental modelsUse The Index, LukedocsPostgreSQL docs - SELECT processingPostgreSQLarticleMode SQL Tutorial - advancedModearticleNULL handling pitfalls (PostgreSQL wiki / community summaries)PostgreSQL wikiCheckpoint
You need users who placed more than 5 orders in March. Which filter placement is correct?
Checkpoint
After joining orders to order_items, SUM(orders.amount) is ~3× finance's number. Most likely cause?
Checkpoint
Which statement about logical SELECT processing is right?
Checkpoint
You want all customers and their latest order_id, including customers with zero orders. Shape?
Checkpoint
Subquery in NOT IN (SELECT user_id FROM bans) returns one NULL user_id. Result of outer query?
Can you state grain, logical execution order, and a null-safe anti-join pattern without notes?
Takeaways
- Lead with grain: one row per X, then prove joins preserve it.
- Logical order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.
- Double counts = fan-out; fix aggregation boundaries, not DISTINCT panic.
- NULL uses three-valued logic; NOT IN is unsafe when nulls appear.
- Narrate edge cases before code - that is the hire signal.
Next: joins and nulls under live interview pressure - inner/outer/semi/anti with real traps.
Sources
Free to read · better with Enzo
Learn it with Enzo
Save your progress, answer the checkpoints, and let Enzo quiz you on what you just read.