SQL gotchas & interview
This chapter is the revision sheet. Everything that silently produces a wrong answer, collected in one place; the classic interview problems worked through; and a short style guide, because readable SQL is a professional skill in itself.
The gotcha catalogue
Ranked by how much damage they do in real work. Every one of these runs successfully — that is what makes them dangerous.
| # | Gotcha | What actually happens | Fix | Ch. |
|---|---|---|---|---|
| 1 | NOT IN with a NULL in the list |
Returns zero rows, no error | NOT EXISTS |
3 |
| 2 | COUNT(*) after a LEFT JOIN |
Zero becomes one | COUNT(right.pk) |
5 |
| 3 | Fan-out in a JOIN | SUM inflated several times over |
Aggregate before joining | 5 |
| 4 | Right-table condition in WHERE of a LEFT JOIN |
Becomes an INNER JOIN silently | Put it in ON |
5 |
| 5 | col <> 'x' when col has NULLs |
NULL rows silently dropped | OR col IS NULL |
2 |
| 6 | Integer division | 12/20*100 = 0 |
* 100.0 |
2 |
| 7 | CASE without a NULL branch first |
Ungraded reported as F | WHEN x IS NULL first |
8 |
| 8 | GROUP BY hides groups with no rows |
Empty categories vanish from the report | LEFT JOIN from the dimension |
4/5 |
| 9 | LAG over a series with missing periods |
Compares the wrong months | Generate the full date series | 6/7 |
| 10 | BETWEEN two dates on a timestamp |
Loses the final day | Half-open >= a AND < b |
3 |
| 11 | ELSE 0 inside AVG(CASE ...) |
Average dragged down by fake zeros | Omit the ELSE |
8 |
| 12 | AVG silently skips NULLs |
Denominator is not what you think | Report COUNT next to AVG |
4 |
| 13 | SUM of zero rows |
Returns NULL, not 0 | COALESCE(SUM(x), 0) |
4 |
| 14 | Selecting a non-grouped column | SQLite/MySQL invent a value | Group it or aggregate it | 4 |
| 15 | LIMIT without ORDER BY |
Arbitrary, unstable rows | Always sort first | 2 |
| 16 | AND / OR without brackets |
AND binds tighter — wrong rows |
Bracket it | 2 |
| 17 | Alias used in WHERE |
"no such column" | Repeat it, or use a CTE | 2 |
| 18 | Window function in WHERE |
Error — computed later | CTE, then filter | 7 |
| 19 | Function wrapped around a filtered column | Index unusable — full scan | Rewrite as a range | 3/11 |
| 20 | UPDATE/DELETE without WHERE |
The entire table changes | SELECT first; use a transaction |
10 |
| 21 | Missing conn.commit() in Python |
Change silently disappears | with conn: |
12 |
| 22 | f-string query building | SQL injection; breaks on O'Brien |
Parameters (?) |
12 |
| 23 | SQLite foreign keys off by default | Invalid references accepted | PRAGMA foreign_keys = ON |
10 |
| 24 | Type mismatch in a join condition | Correct but 1000× slower | Match the column types | 11 |
Sanity-check the row count. Before and after every JOIN, and before and after adding a filter, ask: is this number what I expect? Nearly every gotcha in the table above shows up first as a row count that is quietly wrong — 0 rows, or too many, or one where zero belonged.
How to attack a query task
Under interview pressure students freeze and start typing. Give them a procedure instead.
- Restate the question in one sentence. "For each course, the student with the highest grade." If you cannot say it, you cannot write it.
- Name the output. How many rows should come back — one per course? One per student? What columns?
- Find the tables and the keys. Which tables hold those columns, and what links them?
- Say it out loud in SQL order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. The English sentence usually maps straight onto it.
- Build it in layers. Write the
FROM+JOIN, run it. Add theWHERE, run it. Then group. Never write 30 lines before running anything. - Check the row count at each step. If it jumps after a JOIN, that is fan-out.
- Ask about NULLs and ties. "Should ungraded students count?" "If two tie for first, do you want both?" Interviewers award marks for asking — it shows you have been bitten before.
| When the question says… | You probably need… |
|---|---|
| "for each …" | GROUP BY or PARTITION BY |
| "top N per …" | ROW_NUMBER() OVER (PARTITION BY …) |
| "including those with none / zero" | LEFT JOIN |
| "who never / has no …" | NOT EXISTS or anti-join |
| "compared to the average" | Scalar subquery or AVG() OVER () |
| "month over month / vs previous" | LAG |
| "running / cumulative" | SUM() OVER (ORDER BY …) |
| "more than N times" | HAVING COUNT(*) > N |
| "duplicates" | GROUP BY … HAVING COUNT(*) > 1 |
| "percentage of total" | 100.0 * x / SUM(x) OVER () |
Classic interview problems
These come up again and again. Each is worked on school.db so the answers are verifiable.
1. The second-highest value
The most-asked SQL question in existence. Three solutions, each teaching something different:
-- (a) DENSE_RANK — generalises to Nth immediately, handles ties correctly WITH ranked AS ( SELECT grade, DENSE_RANK() OVER (ORDER BY grade DESC) AS rnk FROM enrollments WHERE grade IS NOT NULL ) SELECT DISTINCT grade FROM ranked WHERE rnk = 2; -- 95 -- (b) LIMIT/OFFSET — simple, but wrong when there are ties SELECT DISTINCT grade FROM enrollments WHERE grade IS NOT NULL ORDER BY grade DESC LIMIT 1 OFFSET 1; -- (c) "the max below the max" — the classic pre-window-function answer SELECT MAX(grade) FROM enrollments WHERE grade < (SELECT MAX(grade) FROM enrollments);
Result 1
| grade |
|---|
| 99 |
1 row
Result 2
| grade |
|---|
| 99 |
1 row
Result 3
| MAX(grade) |
|---|
| 99 |
1 row
2 ms
Say why (a) is the best answer: change rnk = 2 to rnk = 5 and it still works, and ties behave sensibly.
2. Top-N per group
WITH ranked AS ( SELECT course_id, student_id, grade, ROW_NUMBER() OVER (PARTITION BY course_id ORDER BY grade DESC) AS rn FROM enrollments WHERE grade IS NOT NULL ) SELECT * FROM ranked WHERE rn <= 2 ORDER BY course_id, rn;
| course_id | student_id | grade | rn |
|---|---|---|---|
| 1 | 24 | 100 | 1 |
| 1 | 292 | 100 | 2 |
| 2 | 24 | 96 | 1 |
| 2 | 3 | 95 | 2 |
| 3 | 232 | 86 | 1 |
| 3 | 260 | 85 | 2 |
| 4 | 129 | 100 | 1 |
| 4 | 250 | 98 | 2 |
| 5 | 136 | 94 | 1 |
| 5 | 168 | 92 | 2 |
| 6 | 206 | 94 | 1 |
| 6 | 108 | 94 | 2 |
| 7 | 247 | 100 | 1 |
| 7 | 125 | 100 | 2 |
| 8 | 197 | 100 | 1 |
… first 15 of 28 rows
28 rows
1 ms
- Find duplicates
SELECT student_id, course_id, COUNT(*) AS times FROM enrollments GROUP BY student_id, course_id HAVING COUNT(*) > 1; -- (none in our clean data) -- and delete them, keeping the lowest id of each group DELETE FROM enrollments WHERE enrollment_id NOT IN ( SELECT MIN(enrollment_id) FROM enrollments GROUP BY student_id, course_id );
| student_id | course_id | times |
|---|---|---|
| 1 | 3 | 2 |
| 1 | 11 | 2 |
| 2 | 11 | 2 |
| 3 | 6 | 2 |
| 3 | 11 | 2 |
| 4 | 7 | 2 |
| 4 | 15 | 2 |
| 5 | 2 | 2 |
| 5 | 5 | 2 |
| 6 | 4 | 2 |
| 6 | 14 | 2 |
| 7 | 9 | 2 |
| 8 | 1 | 2 |
| 10 | 14 | 2 |
| 11 | 7 | 2 |
… first 15 of 514 rows
514 rows
2 ms
- Rows in A with no match in B
SELECT c.title FROM courses c WHERE NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.course_id = c.course_id); -- Deep Learning
| title |
|---|
| Deep Learning |
1 row
0 ms
- Running total
SELECT enrolled_at, COUNT(*) AS n, SUM(COUNT(*)) OVER (ORDER BY enrolled_at) AS running_total FROM enrollments GROUP BY enrolled_at ORDER BY enrolled_at;
| enrolled_at | n | running_total |
|---|---|---|
| 2025-02-03 | 4 | 4 |
| 2025-02-04 | 14 | 18 |
| 2025-02-05 | 27 | 45 |
| 2025-02-06 | 25 | 70 |
| 2025-02-07 | 32 | 102 |
| 2025-02-08 | 40 | 142 |
| 2025-02-09 | 41 | 183 |
| 2025-02-10 | 38 | 221 |
| 2025-02-11 | 44 | 265 |
| 2025-02-12 | 37 | 302 |
| 2025-02-13 | 46 | 348 |
| 2025-02-14 | 46 | 394 |
| 2025-02-15 | 46 | 440 |
| 2025-02-16 | 44 | 484 |
| 2025-02-17 | 37 | 521 |
… first 15 of 91 rows
91 rows
1 ms
- Median — harder than it looks
There is no
MEDIAN()in standard SQL, so this tests whether you can think:
WITH ordered AS ( SELECT grade, ROW_NUMBER() OVER (ORDER BY grade) AS rn, COUNT(*) OVER () AS n FROM enrollments WHERE grade IS NOT NULL ) SELECT AVG(grade) AS median FROM ordered WHERE rn IN ((n + 1) / 2, (n + 2) / 2); -- 14 grades → rows 7 and 8 → (81 + 83) / 2 = 82.0
| median |
|---|
| 73 |
1 row
1 ms
The (n+1)/2, (n+2)/2 trick handles both cases at once: for an odd count both expressions give the same row, for an even count they give the middle two.
7. Percentage of total
SELECT course_id, COUNT(*) AS n, ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct FROM enrollments GROUP BY course_id ORDER BY pct DESC;
| course_id | n | pct |
|---|---|---|
| 11 | 152 | 8.1 |
| 14 | 148 | 7.9 |
| 10 | 137 | 7.3 |
| 13 | 136 | 7.3 |
| 1 | 134 | 7.2 |
| 2 | 134 | 7.2 |
| 6 | 133 | 7.1 |
| 15 | 132 | 7.1 |
| 5 | 130 | 7 |
| 7 | 129 | 6.9 |
| 9 | 129 | 6.9 |
| 8 | 127 | 6.8 |
| 3 | 124 | 6.6 |
| 4 | 121 | 6.5 |
14 rows
1 ms
- Consecutive / gap analysis
-- Students who enrolled in two courses on the same day SELECT student_id, enrolled_at, COUNT(*) AS same_day FROM enrollments GROUP BY student_id, enrolled_at HAVING COUNT(*) > 1; -- students 1, 3, 5, 6, 7, 8 — each enrolled twice on one day -- Days between a student's consecutive enrollments SELECT student_id, enrolled_at, CAST(JULIANDAY(enrolled_at) - JULIANDAY( LAG(enrolled_at) OVER (PARTITION BY student_id ORDER BY enrolled_at) ) AS INTEGER) AS gap_days FROM enrollments ORDER BY student_id, enrolled_at;
Result 1
| student_id | enrolled_at | same_day |
|---|---|---|
| 3 | 2025-02-19 | 2 |
| 4 | 2025-02-08 | 2 |
| 8 | 2025-02-11 | 2 |
| 11 | 2025-09-09 | 2 |
| 15 | 2025-02-15 | 2 |
| 19 | 2025-02-22 | 2 |
| 20 | 2025-09-17 | 2 |
| 21 | 2025-02-18 | 2 |
| 22 | 2025-02-11 | 3 |
| 24 | 2025-09-12 | 2 |
| 24 | 2025-09-13 | 2 |
| 25 | 2025-02-11 | 2 |
| 26 | 2025-02-16 | 2 |
| 26 | 2025-02-17 | 2 |
| 26 | 2025-02-21 | 2 |
… first 15 of 184 rows
184 rows
Result 2
| student_id | enrolled_at | gap_days |
|---|---|---|
| 1 | 2025-09-06 | NaN |
| 1 | 2025-09-07 | 1 |
| 1 | 2025-09-09 | 2 |
| 1 | 2025-09-17 | 8 |
| 1 | 2025-09-26 | 9 |
| 2 | 2025-02-13 | NaN |
| 2 | 2025-02-14 | 1 |
| 2 | 2025-02-15 | 1 |
| 2 | 2025-02-19 | 4 |
| 2 | 2025-02-25 | 6 |
| 2 | 2025-02-27 | 2 |
| 2 | 2025-03-02 | 3 |
| 3 | 2025-02-17 | NaN |
| 3 | 2025-02-19 | 2 |
| 3 | 2025-02-19 | 0 |
… first 15 of 1866 rows
1866 rows
3 ms
- Pivot without a PIVOT keyword
SELECT course_id, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed FROM enrollments GROUP BY course_id;
| course_id | completed | active | failed |
|---|---|---|---|
| 1 | 89 | 29 | 2 |
| 2 | 99 | 16 | 8 |
| 3 | 60 | 29 | 29 |
| 4 | 77 | 29 | 5 |
| 5 | 83 | 29 | 12 |
| 6 | 96 | 21 | 3 |
| 7 | 95 | 21 | 2 |
| 8 | 90 | 19 | 9 |
| 9 | 92 | 23 | 5 |
| 10 | 108 | 16 | 7 |
| 11 | 91 | 35 | 16 |
| 13 | 92 | 22 | 1 |
| 14 | 96 | 31 | 7 |
| 15 | 71 | 27 | 26 |
14 rows
1 ms
- Above-average members of each group
SELECT enrollment_id, course_id, grade FROM ( SELECT *, AVG(grade) OVER (PARTITION BY course_id) AS course_avg FROM enrollments WHERE grade IS NOT NULL ) WHERE grade > course_avg ORDER BY course_id;
| enrollment_id | course_id | grade |
|---|---|---|
| 96 | 1 | 95 |
| 101 | 1 | 82 |
| 118 | 1 | 82 |
| 142 | 1 | 98 |
| 194 | 1 | 93 |
| 210 | 1 | 95 |
| 235 | 1 | 88 |
| 272 | 1 | 80 |
| 302 | 1 | 83 |
| 321 | 1 | 96 |
| 369 | 1 | 91 |
| 374 | 1 | 87 |
| 411 | 1 | 80 |
| 412 | 1 | 87 |
| 483 | 1 | 99 |
… first 15 of 687 rows
687 rows
1 ms
Writing readable SQL Interviewers judge style, and colleagues have to maintain it. Six rules cover it:
- Keywords uppercase, your names lowercase. The structure becomes visible at a glance.
- One clause per line, one column per line once there are more than three.
- Meaningful table aliases —
s,e,c— nevert1,t2. - Always
ASon computed columns. An unnamed expression is unreadable in the output and in pandas. - CTEs instead of nested subqueries once the query passes ~15 lines. Name each step.
- Comment the why, not the what.
-- exclude test accounts (ticket #412)is useful;-- select the nameis noise.
-- ❌ hard to read select s.first_name,c.title,e.grade from enrollments e join students s on e.student_id=s.student_id join courses c on e.course_id=c.course_id where e.grade>80 order by e.grade desc; -- ✅ the same query SELECT s.first_name, c.title, e.grade FROM enrollments e JOIN students s ON e.student_id = s.student_id JOIN courses c ON e.course_id = c.course_id WHERE e.grade > 80 ORDER BY e.grade DESC;
Result 1
| first_name | title | grade |
|---|---|---|
| Nilufar | Excel for Business | 100 |
| Kamola | Excel for Business | 100 |
| Otabek | Project Management | 100 |
| Madina | Financial Analysis | 100 |
| Bekzod | Project Management | 100 |
| Dilnoza | Excel for Business | 100 |
| Rustam | Graphic Design | 100 |
| Gulnora | Python Basics | 100 |
| Malika | Excel for Business | 100 |
| Zulfiya | Project Management | 100 |
| Shahnoza | Python Basics | 99 |
| Jasur | Python Basics | 98 |
| Zulfiya | Project Management | 98 |
| Nilufar | Data Visualization | 98 |
| Kamola | Excel for Business | 98 |
… first 15 of 338 rows
338 rows
Result 2
| first_name | title | grade |
|---|---|---|
| Nilufar | Excel for Business | 100 |
| Kamola | Excel for Business | 100 |
| Otabek | Project Management | 100 |
| Madina | Financial Analysis | 100 |
| Bekzod | Project Management | 100 |
| Dilnoza | Excel for Business | 100 |
| Rustam | Graphic Design | 100 |
| Gulnora | Python Basics | 100 |
| Malika | Excel for Business | 100 |
| Zulfiya | Project Management | 100 |
| Shahnoza | Python Basics | 99 |
| Jasur | Python Basics | 98 |
| Zulfiya | Project Management | 98 |
| Nilufar | Data Visualization | 98 |
| Kamola | Excel for Business | 98 |
… first 15 of 338 rows
338 rows
1 ms
Full question bank Grouped by topic, for revision or for an exam. Answers are in the chapters shown.
Fundamentals (Ch. 1)
- What is the difference between SQL, a database and a DBMS?
- Primary key vs unique key vs foreign key.
- Is NULL the same as 0 or an empty string? How do you test for it?
- In what order does a
SELECTactually execute? DELETEvsTRUNCATEvsDROP.- Why avoid
SELECT *in production code?
Querying (Ch. 2–4)
- Why can't you use a column alias in
WHERE? WHEREvsHAVING.COUNT(*)vsCOUNT(col)vsCOUNT(DISTINCT col).- Does
AVGinclude NULLs? What doesSUMreturn over zero rows? - Why does
SELECT 7/2return 3? DISTINCTvsGROUP BY.- Will
WHERE city != 'X'return rows where city is NULL?
Joins & subqueries (Ch. 5–6)
- INNER vs LEFT vs FULL OUTER JOIN.
- Why does
COUNT(*)give 1 instead of 0 after a LEFT JOIN? - Condition in
ONvs inWHERE— what changes? - How do you find rows in A with no match in B?
- What is a self join, and why
a.id < b.id? - Your
SUMdoubled after adding a JOIN — why? INvsEXISTSvsJOIN.- CTE vs subquery — when must you use a CTE?
- What are the two parts of a recursive CTE?
Window functions (Ch. 7)
- Window function vs aggregate.
ROW_NUMBERvsRANKvsDENSE_RANK.- How do you get the top 3 per group?
- Why can't you filter a window function in
WHERE? - What does
ORDER BYinsideOVER()change? - How do you compute month-over-month growth?
ROWSvsRANGE.
Functions & sets (Ch. 8–9)
- How does
CASEchoose a branch? How do you build a pivot? - Why is
ELSE 0wrong insideAVG(CASE ...)? - How do you get the last day of a month?
UNIONvsUNION ALLvsJOIN.- What are the requirements for a
UNION? - Why is
EXCEPTsafer thanNOT IN?
Design, performance & Python (Ch. 10–12)
- Explain 1NF, 2NF, 3NF. When would you denormalize?
- What is a transaction? What does ACID stand for?
- What is a view? Does it store data?
- How does an index speed up a query, and what does it cost?
- Does column order matter in a composite index?
- What does "sargable" mean?
- How do you diagnose a slow query?
- What is SQL injection and how do you prevent it?
- Should you filter in SQL or in pandas, and why?
Summary — the SQL half
Thirteen chapters, one small database, and a complete working knowledge of analytical SQL. What a student who has finished Part I can do:
- Read data:
SELECT,WHERE,ORDER BY,LIMIT,DISTINCT, and every filtering operator including the NULL traps. - Summarise: the five aggregates,
GROUP BY,HAVING, and the difference between filtering rows and filtering groups. - Combine: every join type, anti-joins, and — crucially — spot fan-out before it corrupts a report.
- Compose: subqueries,
EXISTS, CTEs and recursive CTEs to build multi-step analyses that stay readable. - Analyse: window functions for rankings, top-N per group, running totals, moving averages and period-over-period change.
- Reshape:
CASE WHENpivots, string and date handling, set operations and unpivoting to long format. - Build and protect: tables, constraints, transactions, normalization and views.
- Make it fast: indexes, sargable predicates and
EXPLAIN QUERY PLAN. - Connect it to Python: parameterised queries into a tidy DataFrame, ready to plot.
- SQL is not executed in the order you write it — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. This explains aliases,
HAVING, and why window functions cannot go inWHERE. - NULL means unknown, so any comparison with it is UNKNOWN and
WHEREkeeps only TRUE. Almost every silent wrong answer traces back here. - Reduce in SQL, analyse in Python. The database is faster than your laptop and it owns the data.
Next — Part II: Chapter 14 begins Seaborn. Every DataFrame we plot from here comes out of a query like the ones you have just learned to write.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.