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.
★ 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.
Four checks before the first keyword
5 min liveBefore 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.
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.
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.
What the database actually does with NULL and groups
5 min liveMost 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.
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.
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.
Solving it while someone watches
5 min liveA 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.
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.
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.
Six bench questions, four checks each, on paper
★ 22 min · everyone buildsThese 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.
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.
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.
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.
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.
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.
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.
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.
Before session 4
30 min- Finish all six queries on paper, each with its four checks in the margin, and bring them to session 4.
- 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.
- 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.
- 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.
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.
What this session teaches, and where it came from
Every fact on this page, and its verification tier, is recorded in the course's source map.
Session 3 cheat sheet · pin this
Ren has six queries on paper and four checks in every margin. Session 4 is the bench: type each query into a real database running in your browser, press Run and check, read the first row that differs when it fails, then load each planted trap and say out loud which line produced it.