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

SQL rounds fail people who can recite JOIN types but cannot say what one row means, when a filter runs relative to GROUP BY, or why a query is correct at a given grain. This lesson installs the execution order, the grain discipline, set-vs-bag thinking, and the narration style that data and backend interviewers score. The rest of the track assumes this model.
Treat every SQL answer as a product of three claims: grain (one row per what?), predicates (what is true of those rows?), and measures (what do we compute?). If you cannot state those before coding, you are guessing. Interviewers at analytics-heavy companies (Stripe, Airbnb, Meta data, product analytics teams) will interrupt a beautiful query the moment grain is ambiguous.
SQL is declarative: you describe the result, the engine plans access. Interview altitude still requires you to reason about logical order, indexes, and row explosion. You are not asked to optimize a planner for fun; you are asked to prove you will not ship a report that double-counts revenue or drops unpaid orders silently.

Logical execution order (memorize cold)

Written order is not run order. Interviewers love: 'Does WHERE see aliases from SELECT?' Answer from this ladder, not from muscle memory.
sql
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."
WHERE vs HAVING. WHERE kills rows before aggregation; HAVING kills groups after. 'Users with more than 3 orders' is HAVING COUNT(*) > 3 (or a subquery). 'Orders from last week' is WHERE on order_date. Mixing them is the #1 junior slip on filter placement.
ON vs WHERE on outer joins. For LEFT JOIN, predicates in ON keep unmatched left rows (right side null-padded). Moving a right-table filter to WHERE often turns the left join into an inner join by eliminating null-extended rows. Always say which behavior you want.

Grain: the non-negotiable first sentence

Before any JOIN, write (or say): Result grain = one row per ___. Examples: one row per user, per user-day, per order line, per session, per invoice. Then check every join: does it preserve that grain or multiply it? Many-to-many bridges (user ↔ tag, order ↔ coupon) are grain killers unless you aggregate first.
sql
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;

Sets, bags, and DISTINCT discipline

SQL is multiset (bag) algebra by default: duplicates are allowed. DISTINCT and GROUP BY collapse bags to sets of keys. Interview trap: DISTINCT as a panic button after a bad join. That hides the bug and often picks an arbitrary non-grouped column in loose modes. Prefer fixing the join or aggregating explicitly.
UNION vs UNION ALL. UNION dedupes (sort/hash cost); UNION ALL concatenates bags. For event logs and metrics pipelines, UNION ALL is usually correct; accidental UNION can drop legitimate duplicate events that share all projected columns.
sql
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)

Comparisons with NULL yield UNKNOWN, not TRUE. WHERE keeps only TRUE rows, so WHERE col = NULL returns nothing; use IS NULL / IS NOT NULL. NOT IN with a NULL in the list is a classic empty-result trap. We deep-dive joins+nulls in L2; here, install the reflex: every filter involving optional columns needs an explicit NULL story.
sql
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);

Subqueries, derived tables, and correlation

A scalar subquery returns one value; a table subquery returns a relation for FROM/IN/EXISTS. Correlated subqueries reference outer columns and re-evaluate per outer row (conceptually). Interviewers accept them when clear; for large data they often want a join + group rewrite. Say the tradeoff: readability vs planner freedom.
sql
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

Use a fixed 30-second opening: restate the question, name grain, list edge cases (null, empty, ties, timezone, deleted rows), then outline tables and join keys. Write the SELECT list last if that keeps you honest about grain. Dry-run two toy rows out loud before claiming done.
  1. 01"How do you approach a SQL problem?" → grain → tables/keys → filters before/after agg → join risks → write → dry-run nulls/dupes.
  2. 02"WHERE vs HAVING?" → WHERE filters rows pre-agg; HAVING filters groups post-agg; SELECT aliases not in WHERE.
  3. 03"Why is my SUM too high?" → fan-out join multiplying parent measure; pre-aggregate child or sum at correct grain.
  4. 04"COUNT(*) vs COUNT(col)?" → COUNT(*) counts rows; COUNT(col) skips NULL in col; COUNT(DISTINCT) skips NULL too.
  5. 05"When DISTINCT?" → when the business wants unique keys and you already fixed grain - not as a join bandage.
  6. 06"ON vs WHERE on LEFT JOIN?" → ON keeps left rows with nulls; WHERE on right cols often filters them out (inner-join effect).
  7. 07"NULL = NULL?" → UNKNOWN; use IS NULL; prefer NOT EXISTS over NOT IN when nulls possible.
  8. 08"UNION or UNION ALL?" → ALL for metrics/events; UNION only when dedupe is the product requirement.
  9. 09"Correlated subquery OK?" → fine for clarity on small sets; rewrite to joins/CTEs when scale or interview asks optimization.
  10. 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 wiki
Interview habit: before you write a join or window, say the grain of the result set out loud (one row per what?), name the filters that apply before aggregation, and sketch a 4-row toy table. That 30-second ritual catches double-counting and null traps faster than staring at syntax.
When stuck live, reduce to two tables and three rows on the whiteboard. Prove the join, then reintroduce filters and aggregates. Interviewers credit recovery more than a first-draft perfect query that you cannot explain.

Checkpoint

You need users who placed more than 5 orders in March. Which filter placement is correct?

AWHERE COUNT(order_id) > 5 after joining orders, no GROUP BYBGROUP BY user_id with HAVING COUNT(*) > 5 and WHERE on March order datesCHAVING order_date BETWEEN March bounds without GROUP BY
Sign up free to answer and see why

Checkpoint

After joining orders to order_items, SUM(orders.amount) is ~3× finance's number. Most likely cause?

ASUM ignores NULL amounts so totals fall and need COALESCEBorder_items fan-out repeated each order's amount once per line item before SUMCNeed DISTINCT inside SUM like SUM(DISTINCT amount)
Sign up free to answer and see why

Checkpoint

Which statement about logical SELECT processing is right?

ASELECT runs first so WHERE can use column aliases from the select listBFROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMITCORDER BY always runs before LIMIT conceptually fails; LIMIT is applied mid-join
Sign up free to answer and see why

Checkpoint

You want all customers and their latest order_id, including customers with zero orders. Shape?

AINNER JOIN customers to orders then GROUP BY customerBFROM customers LEFT JOIN orders (or left join a 'latest order' subquery) so unmatched customers survive with NULL order fieldsCWHERE order_id IS NOT NULL after a left join to keep only matched customers
Sign up free to answer and see why

Checkpoint

Subquery in NOT IN (SELECT user_id FROM bans) returns one NULL user_id. Result of outer query?

AAll users except those with non-null ban rows; NULL ban is ignored safelyBTypically no rows: NULL in the NOT IN list makes the predicate never TRUECDatabase raises an error and aborts the query automatically
Sign up free to answer and see why

Can you state grain, logical execution order, and a null-safe anti-join pattern without notes?

New to itGetting thereConfident

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.