SQL Exercises with Solutions: 15 Practice Problems
On this page 5 sections ▾
These fifteen problems cover the SQL you actually need, in the order it builds: single-table queries, joins, aggregation, subqueries, CASE expressions and CTEs. Every solution below was executed against the sample database described next, so what you read is what the database returns.
Two ways to use this page
Read a problem, write your answer in the playground, then open the solution to compare. Or, if you want your answer checked automatically, the same material is available as 42 interactive exercises where your query is run and compared with the expected result — any correct query passes.
The sample database
All fifteen problems use the same five related tables. It is small enough to reason about and rich enough for real joins and reports. Nothing needs installing: it lives in your browser.
| Table | Rows | Columns |
|---|---|---|
| Departments | 5 | dept_id, dept_name, location, budget |
| Employees | 12 | emp_id, first_name, last_name, email, dept_id, manager_id, hire_date, salary, active |
| Customers | 8 | customer_id, company, contact, country, city, signup_date, tier |
| Products | 7 | product_id, name, category, unit_price, in_stock |
| Orders | 15 | order_id, customer_id, emp_id, product_id, quantity, order_date, status, order_total |
Open the playground in another tab and you can run any query below immediately, or click the Run button on any solution.
The problems
Difficulty is a rough guide, not a gate. If problem 5 stumps you, the JOINs guide explains exactly what it is testing.
Problem 1
EasyList every product with its name and price, most expensive first.
Hint: Two columns after SELECT, then ORDER BY with DESC.
Show the solution
SELECT name, unit_price
FROM Products
ORDER BY unit_price DESC;
Naming the columns instead of using * keeps the output readable and is what you would do in real reporting. ORDER BY sorts ascending by default, so DESC is required for "most expensive first".
Returns all 7 products, Beacon Analytics (1450) first.
Problem 2
EasyWhich countries do our customers come from? Each country should appear once.
Hint: Two customers share a country, so a plain SELECT would list it twice. A duplicate-removing keyword goes straight after SELECT.
Show the solution
SELECT DISTINCT country
FROM Customers
ORDER BY country;
| country |
|---|
| Japan |
| Lebanon |
| Norway |
| Peru |
| Tanzania |
| United Kingdom |
| United States |
● 7 rows · produced by running this query on the sample database
DISTINCT applies to the whole selected row, not just one column, so with a single column it does exactly what you want. Adding ORDER BY makes the list readable rather than storage-ordered.
Returns 7 rows from 8 customers: two of them are in the same country, which is exactly what DISTINCT collapses.
Problem 3
EasyFind active employees in department 10.
Hint: Two conditions joined with AND. Booleans are stored as 1 and 0 here.
Show the solution
SELECT first_name, last_name, salary
FROM Employees
WHERE dept_id = 10 AND active = 1;
Both conditions must hold, so AND is right. Writing active = 1 rather than a bare active is the portable form; SQL Server, for example, will not accept a bare column in WHERE.
Returns the active engineers only.
Problem 4
EasyHow many customers are in each tier?
Hint: COUNT with GROUP BY.
Show the solution
SELECT tier, COUNT(*) AS customers
FROM Customers
GROUP BY tier
ORDER BY customers DESC;
| tier | customers |
|---|---|
| Gold | 3 |
| Platinum | 2 |
| Silver | 2 |
| Bronze | 1 |
● 4 rows · produced by running this query on the sample database
GROUP BY collapses rows that share a tier into one row each, and COUNT(*) counts the rows inside each group. Every non-aggregated column in SELECT must appear in GROUP BY.
Returns one row per tier.
Problem 5
MediumShow each department name with how many employees it has. Departments with nobody should still appear, showing zero.
Hint: Which join keeps rows that have no match on the other side? And COUNT what, exactly?
Show the solution
SELECT d.dept_name, COUNT(e.emp_id) AS headcount
FROM Departments d
LEFT JOIN Employees e ON e.dept_id = d.dept_id
GROUP BY d.dept_name
ORDER BY headcount DESC;
| dept_name | headcount |
|---|---|
| Engineering | 5 |
| Sales | 4 |
| Finance | 2 |
| Marketing | 1 |
| Support | 0 |
● 5 rows · produced by running this query on the sample database
LEFT JOIN keeps every department. The subtle part is COUNT(e.emp_id) rather than COUNT(*): COUNT of a column skips NULLs, so an empty department correctly counts 0, while COUNT(*) would count the single NULL-filled row and report 1.
Returns all 5 departments with their headcounts.
Problem 6
MediumWhat is the average salary per department, rounded to whole rupees, for departments with more than one employee?
Hint: AVG and ROUND, then a filter that runs after grouping.
Show the solution
SELECT d.dept_name, ROUND(AVG(e.salary), 0) AS avg_salary, COUNT(*) AS headcount
FROM Employees e
JOIN Departments d ON d.dept_id = e.dept_id
GROUP BY d.dept_name
HAVING COUNT(*) > 1
ORDER BY avg_salary DESC;
| dept_name | avg_salary | headcount |
|---|---|---|
| Finance | 124000 | 2 |
| Sales | 100000 | 4 |
| Engineering | 99200 | 5 |
● 3 rows · produced by running this query on the sample database
WHERE cannot filter on COUNT(*), because WHERE runs before rows are grouped. HAVING is the filter that runs after grouping, which is the entire reason it exists.
Returns the multi-person departments, highest average first.
Problem 7
MediumList every order with the customer company and the product name.
Hint: Three tables, so two JOIN clauses.
Show the solution
SELECT o.order_id, c.company, p.name AS product, o.quantity, o.order_total
FROM Orders o
JOIN Customers c ON c.customer_id = o.customer_id
JOIN Products p ON p.product_id = o.product_id
ORDER BY o.order_id;
Joins chain naturally: each JOIN adds one more table with its own ON condition. Aliases (o, c, p) keep the query readable and are required once the same column name exists in more than one table.
Returns all 15 orders with names instead of ids.
Problem 8
MediumWhich employees earn more than the company average?
Hint: You need the average before you can compare against it. A query inside a query.
Show the solution
SELECT first_name, last_name, salary
FROM Employees
WHERE salary > (SELECT AVG(salary) FROM Employees)
ORDER BY salary DESC;
The inner query returns exactly one value, so it can sit on the right of a comparison. This is a scalar subquery. It is evaluated once, not per row.
Returns the above-average earners.
Problem 9
MediumWhich customers have never placed a shipped order?
Hint: NOT EXISTS, or a LEFT JOIN where the other side is NULL.
Show the solution
SELECT c.company, c.country
FROM Customers c
WHERE NOT EXISTS (
SELECT 1 FROM Orders o
WHERE o.customer_id = c.customer_id AND o.status = 'Shipped'
)
ORDER BY c.company;
| company | country |
|---|---|
| Baltic Systems | Peru |
| Kilimanjaro Foods | Tanzania |
| Sakura Logistics | Japan |
● 3 rows · produced by running this query on the sample database
NOT EXISTS is the clearest way to say "no matching row exists". It is also safe with NULLs, unlike NOT IN, which in a real database returns no rows at all if the subquery produces a single NULL (the in-browser engine here does not reproduce that, so the playground will not show it).
Returns customers with no shipped order.
Problem 10
MediumLabel each product as Budget (under 200), Mid (200 to 899) or Premium (900 and above).
Hint: CASE WHEN, checked top to bottom.
Show the solution
SELECT name, unit_price,
CASE WHEN unit_price < 200 THEN 'Budget'
WHEN unit_price < 900 THEN 'Mid'
ELSE 'Premium'
END AS price_band
FROM Products
ORDER BY unit_price;
CASE stops at the first matching WHEN, so the second condition only needs its upper bound. ELSE catches everything left; without it, unmatched rows would come back NULL.
Returns all 7 products with a band.
Problem 11
MediumProduce a one-row report: total orders, shipped orders, and cancelled orders.
Hint: SUM over a CASE that yields 1 or 0.
Show the solution
SELECT COUNT(*) AS total_orders,
SUM(CASE WHEN status = 'Shipped' THEN 1 ELSE 0 END) AS shipped,
SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM Orders;
| total_orders | shipped | cancelled |
|---|---|---|
| 15 | 10 | 2 |
● 1 row · produced by running this query on the sample database
This is the conditional-aggregation pattern, and it is how most one-row dashboards are built. Each SUM counts only the rows matching its condition, and all of them scan the table once.
Returns exactly one row with three numbers.
Problem 12
MediumShow revenue per month for shipped orders.
Hint: Group by the year and month parts of the date.
Show the solution
SELECT YEAR(order_date) AS yr, MONTH(order_date) AS mth,
COUNT(*) AS orders, ROUND(SUM(order_total), 0) AS revenue
FROM Orders
WHERE status = 'Shipped'
GROUP BY YEAR(order_date), MONTH(order_date)
ORDER BY yr, mth;
You can group by an expression, not just a column. Filtering in WHERE before grouping is both correct and faster than filtering afterwards.
Returns one row per month that had a shipped order.
Problem 13
HardWhich employee closed the most revenue in shipped orders?
Hint: Join orders to employees, aggregate, sort, take the top row.
Show the solution
SELECT e.first_name || ' ' || e.last_name AS employee,
COUNT(*) AS orders, ROUND(SUM(o.order_total), 0) AS revenue
FROM Orders o
JOIN Employees e ON e.emp_id = o.emp_id
WHERE o.status = 'Shipped'
GROUP BY e.first_name, e.last_name
ORDER BY revenue DESC
LIMIT 1;
Everything you have learned in one query: a join to get names, a filter, an aggregate, and a limit. Note that GROUP BY lists the underlying columns, not the concatenated alias.
Returns a single row: the top seller.
Problem 14
HardUsing a CTE, list the departments whose average salary is above 100,000.
Hint: Name the intermediate step with WITH, then filter on it.
Show the solution
WITH dept_avg AS (
SELECT dept_id, AVG(salary) AS avg_salary
FROM Employees
GROUP BY dept_id
)
SELECT d.dept_name, ROUND(a.avg_salary, 0) AS avg_salary
FROM dept_avg a
JOIN Departments d ON d.dept_id = a.dept_id
WHERE a.avg_salary > 100000
ORDER BY a.avg_salary DESC;
| dept_name | avg_salary |
|---|---|
| Finance | 124000 |
● 1 row · produced by running this query on the sample database
A CTE gives the intermediate result a name, so the final query reads like a sentence instead of a nest of brackets. It is the same work a subquery would do, expressed in the order you think. Most databases also let you compare against another subquery here, for example a company-wide average; this site’s in-browser engine keeps CTEs and scalar subqueries separate, so the threshold is a literal.
Returns the departments averaging above 100,000.
Problem 15
HardBuild a category report: revenue, order count and average order value per product category, for shipped orders only, richest category first.
Hint: Two CTEs, or one join plus grouping. Round the average.
Show the solution
WITH shipped AS (
SELECT o.order_id, o.order_total, p.category
FROM Orders o
JOIN Products p ON p.product_id = o.product_id
WHERE o.status = 'Shipped'
)
SELECT category,
COUNT(*) AS orders,
ROUND(SUM(order_total), 0) AS revenue,
ROUND(AVG(order_total), 0) AS avg_order
FROM shipped
GROUP BY category
ORDER BY revenue DESC;
Separating "which rows count" from "what to compute" into two steps is how real reports stay maintainable. Change the filter once, in the CTE, and every metric below follows.
Returns one row per category that had a shipped order.
What to do next
If most of these went smoothly, you are past the beginner stage. Two useful next steps:
- The 42 interactive exercises — same topics, but your answer is executed and compared, so you get told exactly what is wrong: wrong row count, wrong order, or wrong values.
- Ten analyst interview scenarios — longer, business-shaped questions of the kind asked in interviews.
If several were hard, work through the structured course instead. It covers the same ground in order, with a tutorial before each set of exercises.
Frequently asked questions
Are these SQL exercises free?
Yes. Every problem, solution and explanation on this page is free, and so are the 42 interactive exercises linked from it. There is no signup and nothing to install.
Do the solutions actually run?
Yes. Every solution on this page was executed against the sample database before publishing, and each one has a Run button that opens it in the playground so you can confirm it yourself.
Which database do these exercises use?
They use standard SQL that works on PostgreSQL, MySQL, SQL Server and SQLite, apart from the || string concatenation in problem 13, which is written CONCAT() or + on some systems. The dialect differences page covers those cases.
How do I practise SQL if I do not have a database?
You do not need one. This site runs a real SQL engine inside your browser with the five-table sample database loaded, so you can write and run queries immediately, even offline.
What should I practise after these?
Move to the 42 interactive exercises, which check your answers automatically, then to the analyst interview scenarios. Both are linked above.
Related reading
- How to Find the Second Highest Salary in SQL (5 Ways)
- Find and Delete Duplicate Rows in SQL
- SQL Practice Exercises: 42 problems with instant checking
- Learn SQL Online Free: the structured course
- SQL Scenario Questions for Data Analyst Interviews
- SQL Cheat Sheet: Every Clause and Function on One Page
About the author
SQL Practice
SQL Practice publishes free SQL tutorials, an in-browser playground and 42 practice exercises, built so you can run what you read. Every runnable example in our tutorials is executed against the site’s sample database before it is published. Read how we keep tutorials accurate, or report a mistake.