LeetCode SQL Problems: 249 Questions With Solutions
LeetCode's SQL track is a separate problem set from the algorithm one. Instead of a function to write you get two or three tables and a question about them, and the answer is a single query checked against a fixed set of rows — there is no data structure to choose and nothing to hand-optimise, so the whole difficulty is in saying the question in SQL.
It is the set to work through if you are interviewing for a data analyst, analytics engineer, data engineer or backend role where a live SQL screen is part of the loop, and it rewards the effort faster than the algorithm track does for one reason: the vocabulary is small and finite. A WHERE clause with an ORDER BY, aggregation with GROUP BY and HAVING, a self-join, a ranking window function, a correlated subquery, date arithmetic, NULL semantics, and CASE — those 8 shapes account for every problem featured on this page. Once you can tell on sight which one a question is asking for, most of the Easy and Medium problems are five lines.
- 249 problems
- 100 Easy
- 105 Medium
- 44 Hard
- 8 query shapes
How LeetCode SQL problems break down
A SQL problem does not have an algorithm, it has a shape — the arrangement of clauses that answers the question being asked. There are not many of them, they compose, and the harder problems are two or three of them stacked rather than anything new. Each one below is written out with the signals that give it away and a short query you can read in one sitting. The snippets are MySQL; the only thing that changes between dialects is the date arithmetic, and that difference is stated where it comes up.
- 1Filtering and ordering one table
- 2Aggregation with GROUP BY and HAVING
- 3Self-joins
- 4Window functions: RANK, DENSE_RANK and ROW_NUMBER
- 5Correlated subqueries
- 6Date arithmetic
- 7NULL handling
- 8Pivoting with CASE
Filtering and ordering one table
One table, a WHERE clause and an ORDER BY — the shape most of the Easy problems reduce to.
This is the base case, and it is worth getting exactly right because every other shape is built on top of it. The clauses are evaluated in a fixed order — FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT — and that order explains most of the errors people hit on the way in. An alias defined in the SELECT list is invisible to WHERE, because WHERE ran first, but it is visible to ORDER BY, which ran last. LIMIT applies after the ordering, so "the second highest" is LIMIT 1 OFFSET 1 rather than LIMIT 2, and wrapping that query in an outer SELECT is the standard way to return NULL instead of an empty result when there is no second row. DISTINCT applies to the whole select list rather than to the column it happens to sit in front of, so adding one more column to the projection can silently un-deduplicate the result.
- The question names one table and asks for the rows that match a condition.
- The answer is the nth largest or smallest value of a single column.
- Rows have to be de-duplicated before anything else happens to them.
- The condition is arithmetic on a column: an odd id, a threshold, a remainder.
SELECT name, population, area
FROM World
WHERE area >= 3000000 OR population >= 25000000
ORDER BY population DESC
LIMIT 1 OFFSET 1; -- skip one, take one: the second row, not the top twoPractise it on 176. Second Highest Salary, 595. Big Countries, 620. Not Boring Movies.
Aggregation with GROUP BY and HAVING
Collapse rows into one row per group, then filter the groups.
GROUP BY replaces the rows with one row per distinct value of the grouping key, and every column in the SELECT list then has to be either part of that key or wrapped in an aggregate — anything else is a value picked arbitrarily out of the group, which strict engines reject and lenient ones answer wrongly without telling you. The clause that is misused most is HAVING: WHERE filters rows before the grouping happens, HAVING filters groups after it, so "customers with more than one order" is a HAVING and "orders placed in 2024" is a WHERE. Both belong in the same query more often than not, because filtering the rows first leaves less to group. The other distinction worth memorising is COUNT(*) against COUNT(column): the first counts rows, the second counts rows where that column is not NULL, and on the outer side of a LEFT JOIN those two numbers are different by exactly the amount that matters.
- The question is per-something: per customer, per day, per department.
- The condition is on a count, a sum or an average rather than on a column.
- Duplicates have to be found — group by the thing that repeats, then HAVING COUNT(*) > 1.
- The answer is "the one with the most": group, order by the aggregate, take one.
SELECT customer_id, COUNT(*) AS orders
FROM Orders
WHERE status = 'paid' -- filters ROWS, before grouping
GROUP BY customer_id
HAVING COUNT(*) > 1 -- filters GROUPS, after
ORDER BY orders DESC
LIMIT 1;Practise it on 182. Duplicate Emails, 511. Game Play Analysis I, 570. Managers with at Least 5 Direct Reports and 7 more below.
Self-joins
Join a table to itself so that two of its rows can be compared side by side.
SQL compares columns within a row and never rows with each other, so any question of the form "this row against that row" has to put both rows on one line first — which is all a self-join does. The mechanics are just aliases: name the table twice and let the join condition say how the two copies relate, whether that is e.managerId = m.id for a hierarchy, b.id = a.id + 1 for the next row, or ABS(a.x - b.x) = 1 for a neighbour. Two things go wrong reliably. An equality self-join matches every row with itself, so a comparison on the key (a.id < b.id) is usually needed both to drop those self-pairs and to keep each real pair once instead of twice. And joining consecutive rows on id + 1 is only correct if the ids really are consecutive — the moment rows can be deleted, the gaps break it silently, and LAG over an explicit ordering is the honest version. Three-way self-joins are the shape behind "the same value three times in a row".
- A row is compared against another row of the same table: employee to manager, today to yesterday, seat to next seat.
- The statement says "consecutive", "in a row", or "at least three times running".
- A pair of rows is the answer: the two closest points, mutual follows, duplicated records.
- The table encodes a hierarchy in one of its own columns.
SELECT e.name AS employee, m.name AS manager
FROM Employee AS e
JOIN Employee AS m ON m.id = e.managerId -- one table, two roles
WHERE e.salary > m.salary;Practise it on 180. Consecutive Numbers, 181. Employees Earning More Than Their Managers, 196. Delete Duplicate Emails and 5 more below.
Window functions: RANK, DENSE_RANK and ROW_NUMBER
Rank, number or accumulate within a group without collapsing the rows.
A window function computes over a set of rows related to the current one and, unlike an aggregate, leaves the rows where they are. That is the entire difference: GROUP BY department gives you one row per department, while OVER (PARTITION BY department) gives you every row with its department's answer attached. The three ranking functions differ only in how they treat ties, and picking the wrong one is the most common wrong answer on this track — ROW_NUMBER gives 1, 2, 3, 4 and breaks ties arbitrarily; RANK gives 1, 2, 2, 4, skipping after a tie; DENSE_RANK gives 1, 2, 2, 3, which is what "the top three salaries, ties included" actually means. The mechanical rule to carry into the interview is that a window function cannot appear in WHERE or HAVING, because both are evaluated before the window is: the ranked query has to be wrapped in a subquery or a CTE and filtered from outside. SUM(...) OVER (PARTITION BY x ORDER BY d) is a running total, and LAG and LEAD reach the previous or next row directly, which beats a self-join on id whenever the ids might have gaps.
- Top N per group, where the treatment of ties is part of the question.
- Every row needs its position within its own group attached to it.
- A running total, a cumulative count, or a moving window over an ordering.
- The previous or the next row's value is needed — LAG and LEAD rather than an arithmetic self-join.
SELECT department, name, salary
FROM (
SELECT department, name, salary,
DENSE_RANK() OVER (PARTITION BY department
ORDER BY salary DESC) AS place
FROM Employee
) AS ranked
WHERE place <= 3; -- filtered outside: WHERE is evaluated before the windowPractise it on 177. Nth Highest Salary, 178. Rank Scores, 185. Department Top Three Salaries and 1 more below.
Correlated subqueries
A subquery that mentions the outer row, and therefore runs once per outer row.
An ordinary subquery is evaluated once and its result substituted in. A correlated one refers to a column of the query around it, so it is conceptually re-evaluated for every outer row — and that is what lets it express "…than any other row in its own group". Written out, it reads almost exactly like the sentence in the problem statement, and it keeps ties, which an ORDER BY with LIMIT 1 silently discards. EXISTS is the same construction with the result thrown away: it stops at the first matching row, so it is the right form when the question is only whether something exists, and NOT EXISTS is the anti-join that keeps working when the inner column is nullable, which NOT IN does not. The cost is real, because the inner query runs per row rather than once, so the window-function form of the same question is usually the faster one — both are accepted, and the correlated version is usually the one you can write correctly first.
- The maximum or minimum within each group, with ties kept.
- A row is compared against an aggregate of the rows around it.
- The question is about existence rather than about a value — EXISTS and NOT EXISTS.
- The statement can be read out loud as "…where there is no other row such that…".
SELECT e.name, e.salary
FROM Employee AS e
WHERE e.salary = (SELECT MAX(x.salary)
FROM Employee AS x
WHERE x.department = e.department); -- re-runs per outer rowPractise it on 184. Department Highest Salary, 512. Game Play Analysis II.
Date arithmetic
Compare, bucket and difference dates as dates, not as the strings they print as.
A date is its own type, and the first rule is not to compare it as text: '2024-9-1' and '2024-09-01' are the same day and different strings. Adding an interval to a column is how "the day after" is expressed, and joining on that expression is how every consecutive-day problem is written. This is the one place the dialects visibly diverge — MySQL writes d + INTERVAL 1 DAY, PostgreSQL writes d + INTERVAL '1 day', Oracle writes d + 1 — while everything else on this page is the same in all of them. Keep the arithmetic on the constant side of a comparison wherever you can: a half-open range over the raw column can use an index and a function wrapped around the column cannot, and the wrapped form also changes the type on one side of the comparison. Grouping by month means truncating the date to the month and grouping on the truncated value, and a "within the last 30 days" filter is half-open for the same reason a month bucket is — the closed form counts the boundary day twice.
- Consecutive days, "the next day", "the day before".
- Rows are bucketed by month, quarter or year.
- A rolling window: the last seven days, the trailing three months.
- The gap between two timestamps is either the answer or the filter.
SELECT today.id
FROM Weather AS yesterday
JOIN Weather AS today
ON today.recordDate = yesterday.recordDate + INTERVAL 1 DAY
WHERE today.temperature > yesterday.temperature;Practise it on 197. Rising Temperature, 550. Game Play Analysis IV, 615. Average Salary: Departments VS Company.
NULL handling
NULL means unknown — not zero, not empty — and most wrong answers on this track are made of it.
Every comparison against NULL evaluates to unknown, and a WHERE clause keeps only the rows that evaluate to true, so referee_id <> 2 throws away every row whose referee_id is NULL instead of keeping it. Those rows have to be asked for explicitly with IS NULL, or given a stand-in value with COALESCE. The same rule wrecks NOT IN: if the subquery returns even a single NULL, then x NOT IN (…) is unknown for every x and the query returns nothing at all — which is why the anti-join, a LEFT JOIN with an IS NULL check on the right-hand side, is the safe way to write "customers who never ordered". The aggregate side has the mirror image of the problem: SUM and AVG ignore NULLs entirely, so an average over a column of NULLs is NULL rather than zero, and COUNT(column) skips them while COUNT(*) does not. On the outer side of a LEFT JOIN that difference is the answer: COUNT(right.id) counts real children and COUNT(*) counts one for a parent that has none.
- The words "never", "no matching", or "has not yet" appear — that is an anti-join.
- Rows have to survive a join that found nothing to match them with.
- A nullable column is being filtered with <> or NOT IN.
- Something is counted or averaged over a column that is allowed to be empty.
-- a comparison never matches NULL, so those rows must be asked for
SELECT name FROM Customer WHERE referee_id <> 2 OR referee_id IS NULL;
-- an anti-join: keep only the rows the LEFT JOIN found no partner for
SELECT c.name
FROM Customers AS c
LEFT JOIN Orders AS o ON o.customerId = c.id
WHERE o.id IS NULL;Practise it on 175. Combine Two Tables, 183. Customers Who Never Order, 577. Employee Bonus and 3 more below.
Pivoting with CASE
Turn rows into columns, and conditions into numbers, by putting CASE inside an aggregate.
CASE is an expression, so it can go anywhere a column can — including inside an aggregate, which is where it stops being a formatting device and becomes a technique. SUM(CASE WHEN cond THEN 1 ELSE 0 END) counts the rows in a group that satisfy a condition, and dividing that by COUNT(*) gives the group's rate, which ROUND then trims to whatever precision the problem asked for. MAX(CASE WHEN key = 'x' THEN value END) moves one value per key into a column of its own, which is the pivot from tall to wide. The asymmetry between those two is the thing to remember: use ELSE 0 when you are counting, and omit ELSE entirely when you are pivoting, because a zero would compete with the real value inside the MAX while a NULL is ignored by it. The same expression covers the problems that ask for a computed label rather than a stored one, and inside an UPDATE it is how a column is rewritten in both directions at once — something no pair of sequential UPDATE statements can do without a temporary value.
- A rate, a percentage, or "how many of these are that" per group.
- One column of keys has to become several columns of values.
- The output is a label chosen by a condition rather than a value read from a row.
- Two values have to be swapped in place in a single statement.
SELECT student,
MAX(CASE WHEN subject = 'Maths' THEN score END) AS maths, -- no ELSE
MAX(CASE WHEN subject = 'Physics' THEN score END) AS physics,
ROUND(SUM(CASE WHEN score < 50 THEN 1 ELSE 0 END)
/ COUNT(*), 2) AS fail_rate
FROM Marks
GROUP BY student;Practise it on 262. Trips and Users, 578. Get Highest Answer Rate Question, 608. Tree Node and 4 more below.
The 43 SQL problems to start with
Every Database problem numbered 750 or below whose page here carries the question restated in plain English, a worked example with real rows, and a working solution. Each row is labelled with the query shape it drills, so the table doubles as a syllabus: take a shape, do its problems, move on. Free problems come first and the LeetCode Premium ones after them, each in ascending problem-number order.
All 249 LeetCode SQL problems by difficulty
Every problem in the library that LeetCode tags Database, grouped by LeetCode's own difficulty rating. 33 of the 249 carry a complete solution with a worked example; the rest are listed for completeness, with the LeetCode Premium ones marked — a Premium problem's statement is paywalled on LeetCode itself, so there is only so much any page can say about it.
Easy (100)
Medium (105)
Hard (44)
The pandas versions of the same questions
LeetCode also publishes 15 problems tagged Pandas, which put the same kind of relational question to a DataFrame instead of a table — a merge where this page has a join, a groupby where it has a GROUP BY. They are counted and listed separately because LeetCode publishes them as separate problems, and because the syntax you are being tested on is different even when the shape is not.
LeetCode SQL FAQ
How many LeetCode SQL problems are there?
This library carries 249 problems that LeetCode tags Database — 100 Easy, 105 Medium and 44 Hard — plus 15 more that ask similar questions of a pandas DataFrame instead of a table.
Which LeetCode SQL problems should I do first?
Start with the 43 problems numbered 750 and below, listed on this page with the query shape each one drills. They are the lowest-numbered problems on the track and between them they cover 8 of the 8 query shapes, so one pass through that table means meeting each of those shapes in a real problem rather than only in a snippet.
What is the difference between WHERE and HAVING?
WHERE filters rows before they are grouped; HAVING filters the resulting groups afterwards. A condition on a column is therefore a WHERE, and a condition on a COUNT, SUM or AVG is a HAVING. Both belong in the same query more often than not, because filtering the rows first leaves fewer of them to group.
Do I need window functions to solve LeetCode SQL problems?
Not for most of them, but the ranking problems are much shorter with one. ROW_NUMBER, RANK and DENSE_RANK differ only in how they treat ties — 1, 2, 3, 4 against 1, 2, 2, 4 against 1, 2, 2, 3 — and choosing the wrong one is the most common wrong answer on this track. A window function cannot be used in WHERE or HAVING, so the ranked query has to be wrapped in a subquery or a CTE and filtered from outside.
Why does my NOT IN subquery return no rows?
Because the subquery returned a NULL. Every comparison against NULL evaluates to unknown, so x NOT IN (1, 2, NULL) is unknown for every x and the WHERE clause keeps nothing at all. Use a LEFT JOIN with an IS NULL check on the right-hand side, or NOT EXISTS, whenever the inner column is nullable.
Which SQL dialect are the queries on this page written in?
MySQL. Everything except the date arithmetic is standard SQL and runs unchanged elsewhere: MySQL writes d + INTERVAL 1 DAY, PostgreSQL writes d + INTERVAL '1 day' and Oracle writes d + 1. The query shapes themselves are identical in every dialect.
Keep exploring
When the SQL screen is live and the schema is new
Stealth Interview is a desktop app for macOS and Windows. It reads the coding problem off your screen, returns a working solution with a step-by-step explanation and its time and space complexity, and transcribes what the interviewer is saying — while staying invisible to screen sharing.