learn-interview-prep-with-phoebe / Session 3 of 6
Learn Interview Prep with Phoebe · Session 3 of 6

The SQL round

Most wrong answers in a SQL round are not syntax errors. They are queries that run, return a plausible number, and answer a slightly different question: orders counted as items, undelivered orders scored as late, a filter applied before the average instead of after it, the last day of the year quietly dropped. Every one of those is caught before typing, by reading the question for four things and saying them out loud. Tonight we learn the four checks, read the NULL and grouping rules where the database itself writes them down, look at what a randomized trial found about solving problems while someone watches, and write all six of next session's bench queries on paper.

🟡 Core Anyone with a SQL round in the loop Bring paper and a pen 45 min
0-3 · Welcome 3-18 · Read, NULLs, watched 18-40 · Build-along: six queries on paper 40-45 · Q&A
Part 0

Where we start

Ren, our constructed analyst, rated SQL strong in session 1 and is right to. Ren writes queries every day. That is exactly the risk: daily SQL runs against tables Ren already knows, where the grain and the ids were learned years ago. An interview hands over an unfamiliar schema and a clock. Tonight Ren practises the part that daily work skips, reading a new schema and a new question out loud before writing anything, on the same three tables the session 4 bench uses.

🎫 Clear all three quiz questions to stamp your passport Live = presented in the 45 minutes Self-study = the full depth, after class ★ Build-along = write it yourself

★ What you walk out with tonight. Four checks you can say out loud in the first minute of any SQL question, the NULL, WHERE and HAVING rules in the database's own words, a plan for practising while watched, and six queries written on paper, ready to run against the session 4 bench.

The schema used all night. A real sample of the Olist Brazilian e-commerce dataset (CC BY-NC-SA 4.0): 2,000 people, 2,085 orders, 2,359 order items, 775 sellers. customers(customer_id, customer_unique_id, state) · orders(order_id, customer_id, status, purchase_ts, delivered_date, estimated_date) · order_items(order_id, item_no, product_id, seller_id, price). purchase_ts is a timestamp stored as text; delivered_date is a date or NULL. One line matters more than the rest: customer_id is issued per order, and customer_unique_id is the person. SQL as a subject lives in Learn SQL with Phoebe ↗; here it is a round.

Part 1 · covers reading the question before typing

Four checks before the first keyword

5 min live

Before you type, say four things. What is one row of the answer, and what is one row of each table you will touch? What identifies the thing being counted? Where can NULL appear? What range, and is a row order asked for? Each takes about ten seconds. Together they catch most of the planted traps an interviewer can set, and they let the interviewer hear you reason before there is anything on the screen to judge.

ONE ORDER FANS OUT INTO ITS ITEM ROWS AFTER A JOIN ORDERS: ONE ROW PER ORDER AFTER JOIN: ONE ROW PER ITEM WHAT YOU COUNT Order A · 1 item Order B · 3 items Order C · 1 item A · item 1 B · item 1 B · item 2 · order B again B · item 3 · order B again C · item 1 COUNT(*) 5 rows COUNT(DISTINCT order_id) 3 orders Toy data. In the sample 203 of 2,085 orders have several items, so the Q2 trap counts 850; the answer is 802.
🔍 Click to zoom - a join changes what one row means, and COUNT(*) counts whatever one row now is
LiveGrain: what is one row?2 min▶

Grain is the answer to "what does one row represent?". In orders one row is an order. In order_items one row is an item within an order. Join the two and one row becomes an item again, with the order's columns copied onto every item it has. Nothing warns you. The query runs, and any COUNT(*) after the join counts items.

Ask grain twice: once for each table you will touch, and once for the answer the question wants. "How many orders include at least one item priced above 100?" wants one number counting orders. The join's grain is items. That mismatch is the whole trap, and COUNT(DISTINCT o.order_id) or an EXISTS subquery closes it. In the sample, 203 of the 2,085 orders have more than one item, which is why the trap reads 850 instead of 802: 48 extra item rows.

LiveIds: what identifies the thing you count?2 min▶

A column called customer_id sounds like a person. In this dataset it is issued with each order, so every customer_id has exactly one order and "customers with more than one order" grouped by it returns nothing. The person is customer_unique_id. The schema note says so in one line, and the question rewards whoever read it.

Say it out loud: "the question counts people, so I need the column that identifies a person; is that customer_id or customer_unique_id?" If the schema does not say, ask the interviewer. Asking what identifies an entity is an ordinary professional question, and in most rooms it earns credit rather than costing it.

Self-studyRange and order: what slice, and sorted how?2 min▶

Two last questions. What range does the question ask for, and is the boundary a date, a timestamp, or text that looks like either? And does it ask for a row order? "Orders per month in 2017, in month order" asks for one; "count the orders in each status" does not. A checker that compares rows will fail a correct answer returned in the wrong order when an order was asked, and should not when none was.

Then the fourth check from the title, NULL, which Part 2 takes in full. Write the four answers in the margin before the query: one row is ..., the id is ..., NULL can appear in ..., range and order are .... It looks slow. It is about forty seconds, and it is the forty seconds the interviewer most wants to hear.

Real world

The id that is not a person, in a real public dataset. The Olist data the bench samples is a real public dataset, and its customer table really does issue a fresh customer_id with every order. Anyone who groups by it to find repeat buyers gets zero and, if they are not reading the schema, may report zero. In the session 4 sample the right answer is 57 people with more than one order; the trap returns 0. Nothing in the query is wrong as SQL. The identity check was skipped.

Part 2 · covers NULLs, aggregates, WHERE and HAVING, read at source

What the database actually does with NULL and groups

5 min live

Most SQL folklore about NULL is roughly right and occasionally wrong in the way that costs an interview. The fix is to read what the engine says it does. The rules below were read in the SQLite documentation, which is what the session 4 bench runs, and SQLite states that its NULL handling was made to "work the same as Oracle, PostgreSQL, and DB2". Check your own dialect if the round names a different one.

WHERE A ROW FILTER LANDS, AND WHERE A GROUP FILTER LANDS WHERE price > 150 · trap, 18 sellers HAVING AVG(price) > 150 · 24 sellers FROM / JOIN WHERE GROUP BY HAVING SELECT ORDER BY rows joined; outer NULLs added row filter: drops false and NULL rows become groups group filter, once per group aggregates and aliases computed order only if the question asks SQLite calls this order "purely illustrative": the engine may run it differently, the result is as if. A row filter runs before groups exist; a group filter runs after. Q4 needs the second.
🔍 Click to zoom - a condition on single rows belongs left of GROUP BY, a condition on a group's average belongs right of it
LiveNULL inside a CASE inside AVG: Q32 min▶

The documentation is precise: avg() "returns the average value of all non-NULL X within a group", and its result "is NULL if there are no non-NULL inputs". In the same family, count(X) counts the rows where X is not NULL while count(*) counts rows, and sum() over no non-NULL values is NULL where total() returns 0.0.

Now the trap. Q3 asks what share of delivered orders arrived on or before the estimated date. 59 orders in the sample were never delivered, so their delivered_date is NULL. CASE WHEN delivered_date <= estimated_date THEN 1 ELSE 0 END sends a NULL comparison to the ELSE branch, so every undelivered order is scored as late and lands in the denominator. The trap reads 0.916; restricted to delivered orders, as asked, the answer is 0.943. Either filter with WHERE delivered_date IS NOT NULL, or let the undelivered rows produce NULL so that avg() skips them. Then say which you did, and why.

LiveWHERE filters rows, HAVING filters groups: Q42 min▶

SQLite excludes rows where the WHERE expression evaluates "to either false or NULL", and HAVING "is evaluated once for each group". Q4 asks for sellers with at least five items whose average price over all their items is above 150. Put price > 150 in WHERE and the cheap items are gone before any average is taken: every surviving average is above 150 by construction, and "at least five items" has become "at least five pricey items".

The reference returns 24 sellers; the WHERE version returns 18. The condition on the group's average belongs in HAVING COUNT(*) >= 5 AND AVG(price) > 150. One more rule from the same page: an aggregate query without GROUP BY "always returns exactly one row", which is why SELECT COUNT(*) FROM orders WHERE 1 = 0 returns a single row holding 0, not an empty result.

Self-studyHalf-open date ranges: Q52 min▶

purchase_ts is a timestamp stored as text. BETWEEN '2017-01-01' AND '2017-12-31' compares strings, and a value such as '2017-12-31 09:14:00' sorts after '2017-12-31', so every order placed during 31 December falls outside the range. In the sample that is 2 orders, and December reads 109 instead of 111 while the other eleven months are right. Write the range half-open, purchase_ts >= '2017-01-01' AND purchase_ts < '2018-01-01', and the edge disappears whatever the time of day.

Self-studyThe LEFT JOIN that a WHERE quietly undoes2 min▶

For an outer join, the NULL rows for unmatched records "are added after ON clause processing but before WHERE clause processing". So a LEFT JOIN from orders to order_items keeps orders with no items, padding the item columns with NULL, and then WHERE i.price > 100 removes exactly those padded rows, because a comparison with NULL is not true. The query is now an inner join wearing a LEFT JOIN's name. Move the condition into the ON clause when you mean "join only the pricey items but keep every order", and leave it in WHERE when you mean "only orders that have a pricey item".

The documentation also says its own step order is "purely illustrative": engines are free to execute in any order that gives the same result. Use the pipeline in the figure as a way to reason about where a filter applies, not as a claim about what the engine physically does first.

Real world

Undelivered is not late. In the real Olist sample, 59 orders never reached the customer. A dashboard that scores them as late and a dashboard that leaves them out report different on-time shares, 0.916 and 0.943, from identical data. Neither number is a typo. They answer different questions, and only one of them is the question asked. In a round, saying "undelivered orders are NULL here, and the question restricts to delivered, so I will filter them out" is the sentence that separates the two.

Part 3 · covers thinking aloud while someone watches

Solving it while someone watches

5 min live

A SQL round is not only a test of SQL. It is SQL done while a stranger watches and the clock runs, which is a different task from SQL at your desk. One small randomized trial measured how different, and its answer is a reason to change how you practise rather than a reason to worry.

THE SAME QUERY, SILENT OR NARRATED BEHROOZI ET AL. 2020 · WATCHED VS PRIVATE WHITEBOARD · 48 STUDENTS · RANDOMIZED Silent: the room hears typing Six quiet minutes, then a number A wrong number leaves no reasoning to credit Stuck reads as stopped Practised alone, at a desk Narrated: the room hears the checks Grain, ids, NULLs and range said first A tiny case tested before the full run Stuck sounds like "next I would check..." Practised out loud, with someone watching Being watched is the one condition you cannot remove, so practise inside it, out loud.
🔍 Click to zoom - narration turns the silent minutes into evidence the interviewer can score, right or wrong
LiveWhat the trial did, and what it can and cannot tell you2 min▶

Behroozi, Shirolkar, Barik and Parnin (2020, ESEC/FSE) randomly assigned 48 computer science students to solve a technical problem on a whiteboard either privately or while an interviewer watched. Their abstract reports that "performance is reduced by more than half, by simply being watched by an interviewer". Only the abstract was read for this page.

Read it at its real size. It is one study, 48 people, students rather than working analysts, and a whiteboard problem rather than a SQL round. It does not tell you how much any particular interview costs you. What it does show, cleanly because it was randomized, is that being watched is not a neutral background: the same person, given the same kind of problem, does measurably worse with an audience. That is a practice problem, and practice problems have practice answers.

LivePractise the round in the condition it is held in2 min▶

If watching changes performance, then practice done alone at a desk trains a slightly different skill from the one the round tests. Three moves close the gap. Practise with someone watching, even a friend who knows no SQL; a video call with the camera on is enough. Narrate the four checks every time, so that the first minute of a real round is a habit rather than a decision. And test on a tiny case first: before the full query, write the three-row example you expect, as in Part 1's toy, and say what it should return.

Narration also changes what the interviewer can score. A silent wrong answer is just wrong. A narrated wrong answer shows the four checks, the plan and the place it went off, and most rubrics have lines for reasoning as well as for the final number. How you sound while you do it, voice and pace and presence, is delivery, and Learn Public Speaking with Phoebe ↗ covers it.

Self-studyWhen you are stuck, say what you would check next2 min▶

Everyone stalls in a round at some point. Silence is the expensive version. The cheap version is one sentence: "I am not sure the join keeps one row per order, so next I would check the row count before and after the join." It tells the interviewer what you know, what you doubt and how you would find out, which is most of what an analyst does on a normal Tuesday.

Nerves in a round are real and are treated here as a workload and practice question: rehearsal in the watched condition, a fixed opening routine (the four checks), and fewer decisions left for the day. Nothing here is medical or clinical advice.

Real world

Half the performance, from one change in the room. The students in Behroozi and colleagues' trial were not given harder problems in the watched condition, or less time. The only thing that changed was a person standing there. Whatever interviewers do with that finding, the candidate's version is simple: if the audience is part of the test, the audience belongs in the practice.

Build-along

Six bench questions, four checks each, on paper

★ 22 min · everyone builds

These are the six questions on the session 4 bench, as the bench words them. For each one, Ren says the four checks out loud, writes the answers in the margin, then writes a query on paper. Cover Ren's box and do yours first, out loud, ideally with someone listening. Do not run anything tonight: next session you type your paper queries into the bench and the checker tells you which ones match the reference, row by row.

SIX QUESTIONS, FOUR CHECKS: WHERE EACH PLANTED TRAP IS CAUGHT QUESTION 1 · ONE ROW IS 2 · WHO IS IT 3 · WHERE NULL 4 · RANGE, ORDER Q1 Orders by status Q2 Order with item > 100 Q3 On-time share Q4 Sellers, 5+ items Q5 Orders per month Q6 Repeat customers a status order_id none expected all; any order an order, not item order_id, distinct price: check one number a delivered order order_id delivered_date 3 decimals seller, all items seller_id price: check any order a month of 2017 order_id none expected half-open, sorted a person customer_unique_id none expected one number Coral = the check that catches that question's planted trap. Q1 has none; it is the warm-up.
🔍 Click to zoom - every trap on the bench sits in one of the four checks, so saying all four out loud catches all five

Q1 · Count the orders in each status. The warm-up, and the place to make the four checks a habit while nothing is at stake. Two columns, status then count, one row per status. Say all four checks even though none of them bites.

★ Step 1 · Ren's margin and query1 one row of the answer = a status; orders is one row per order 2 counting orders: order_id, no join needed 3 NULL: status is not expected to be NULL; say so 4 all orders, no row order asked SELECT status, COUNT(*) AS n_orders FROM orders GROUP BY status;

Q2 · How many orders include at least one item priced above 100? One number. The join to order_items changes the grain to items, so name that before you write the join, then write a second correct version so you have a check on the first.

★ Step 2 · Ren's margin and query1 answer = one number counting ORDERS; after the join one row = an ITEM 2 order_id identifies the order; count it DISTINCT 3 NULL: a NULL price fails "> 100", which is fine here 4 all orders; one number, no order SELECT COUNT(DISTINCT o.order_id) AS n_orders FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE i.price > 100; -- second form, same answer expected: SELECT COUNT(*) FROM orders o WHERE EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.order_id AND i.price > 100);

Q3 · Of the delivered orders, what share arrived on or before the estimated date? Round to 3 decimals. The question tells you the NULL rule in brackets: delivered means delivered_date is not NULL. Say where the NULLs are and what you will do with them before writing the CASE.

★ Step 3 · Ren's margin and query1 one row = a DELIVERED order; the denominator is delivered orders only 2 order_id; no join 3 NULL: delivered_date is NULL for undelivered orders; filter them out, because CASE ... ELSE 0 would score them as late 4 all delivered orders; one number, 3 decimals SELECT ROUND(AVG(CASE WHEN delivered_date <= estimated_date THEN 1.0 ELSE 0.0 END), 3) AS on_time_share FROM orders WHERE delivered_date IS NOT NULL;

Q4 · Which sellers sold at least 5 items and have an average item price above 150? Return seller_id and the average rounded to 2 decimals. Say "average over all of the seller's items" out loud; it tells you the filter must wait until after the group exists.

★ Step 4 · Ren's margin and query1 one row = a seller; the average is over ALL their items 2 seller_id, straight from order_items 3 NULL: avg() skips NULL prices; say it, do not rely on it silently 4 no range; no row order asked both conditions are about the GROUP, so both go in HAVING, not WHERE SELECT seller_id, ROUND(AVG(price), 2) AS avg_price FROM order_items GROUP BY seller_id HAVING COUNT(*) >= 5 AND AVG(price) > 150;

Q5 · How many orders were placed in each month of 2017? Month as YYYY-MM and the count, in month order. Two of the four checks matter here: the range edge on a text timestamp, and a row order that is asked for.

★ Step 5 · Ren's margin and query1 one row = a month of 2017 2 order_id; no join 3 NULL: purchase_ts not expected to be NULL; say so 4 range: purchase_ts is TEXT with a time, so half-open, not BETWEEN order: ASKED, month ascending SELECT substr(purchase_ts, 1, 7) AS month, COUNT(*) AS n_orders FROM orders WHERE purchase_ts >= '2017-01-01' AND purchase_ts < '2018-01-01' GROUP BY month ORDER BY month;

Q6 · How many customers placed more than one order? One number. The word "customers" means people, and the schema note says which column is a person. This is the question where reading the schema for ten seconds is worth the whole round.

★ Step 6 · Ren's margin and query1 answer = one number counting PEOPLE 2 customer_id is issued per order; the person is customer_unique_id 3 NULL: none expected in the ids; say so 4 all orders; one number SELECT COUNT(*) AS repeat_customers FROM (SELECT c.customer_unique_id FROM orders o JOIN customers c ON c.customer_id = o.customer_id GROUP BY c.customer_unique_id HAVING COUNT(*) > 1);

Write your own before reading Ren's. Ren's queries are one correct shape each, not the only one; the bench accepts other correct forms too, such as EXISTS for Q2 or strftime for Q5. Keep your paper. In session 4 you type each query in, press Run and check, and see the reference result next to yours. The numbers belong to the bench, so run them there rather than taking anyone's word for them.

Homework

Before session 4

30 min
  1. Finish all six queries on paper, each with its four checks in the margin, and bring them to session 4.
  2. Do one of the six out loud with someone watching, on a video call if needed. Note the moment you went quiet and what you would say there next time.
  3. Write a tiny test case for Q2: three orders, five item rows, as in Part 1's toy, and the result each of your two queries should return on it.
  4. Open the schema of one table you use at work and answer the four checks for it in one line each. If you cannot say what identifies a person, that is a question to ask at work tomorrow.
Check yourself

Three questions

Clear all three and session 3 gets stamped in your passport.

1 · Ren joins orders to order_items, filters items priced above 100, and writes COUNT(*). The result is larger than the number of orders that qualify. Why?

The join changed the grain from orders to items. In the sample that turns the right 802 into the trap's 850. A is backwards: COUNT(*) counts rows, NULL or not. B confuses this with Q4, where the condition is on a group. C is not a rule; the join returns exactly one row per matching item.

2 · Q3 asks for the on-time share of delivered orders, and 59 orders have a NULL delivered_date. What does CASE WHEN delivered_date <= estimated_date THEN 1 ELSE 0 END inside AVG do to them, over all orders?

avg() does skip NULL inputs, but this CASE never hands it a NULL: the ELSE branch turns each one into 0. That is how the trap reports 0.916 instead of 0.943. Filter with WHERE delivered_date IS NOT NULL, or let the undelivered rows produce NULL, and say which you chose.

3 · What is the most useful reading of Behroozi and colleagues' 2020 trial for someone preparing a SQL round?

The trial is 48 students on a whiteboard problem, so it cannot put a number on any one interview, which rules out A. It compared watched with private solving, not silent with narrated, so B claims something it did not test. It is a practice finding, not a medical one, so D is wrong; the move is to rehearse in the condition the round is held in.

Sources covered

What this session teaches, and where it came from

✓SQLite documentation: lang_aggfunc.html, lang_select.html, nulls.htmlavg() over non-NULL values, count(X) vs count(*), sum vs total, WHERE drops false or NULL, HAVING once per group, one row without GROUP BY, outer-join NULL rows before WHERE, the "purely illustrative" order; read at source
◐Behroozi, Shirolkar, Barik and Parnin (2020), ESEC/FSERandomized trial, 48 computer science students, private vs watched whiteboard; abstract read; small sample, students not hires
✓Olist, Brazilian E-Commerce Public Dataset (Kaggle), CC BY-NC-SA 4.0The session 4 bench sample: 2,000 people, 2,085 orders, 2,359 items, 775 sellers; every trap number on this page is from the bench canon
◐SQL as a subjectJoins, aggregation and window functions in depth live in Learn SQL with Phoebe ↗; here SQL is treated as an interview round
◐Ren and Ren's margin notesConstructed for teaching; no real employer, process or question bank is described

Every fact on this page, and its verification tier, is recorded in the course's source map.

Session 3 cheat sheet · pin this

Four checks, out loudOne row is ... · the id is ... · NULL can appear in ... · range and order are ....
A join changes the grainOrders joined to items is one row per item. Count orders with COUNT(DISTINCT) or EXISTS.
NULL in a CASE is not skippedavg() skips NULL inputs, but ELSE 0 turns NULL into 0. Filter, or let it stay NULL.
WHERE rows, HAVING groupsA condition on an average belongs in HAVING. A WHERE on the right table undoes a LEFT JOIN.
Half-open ranges>= start AND < next start. BETWEEN on text timestamps drops the last day.
Practise watchedBeing watched more than halved performance in one small trial. Rehearse with an audience.