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.
Similar graded exercise: Just the names and prices
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.
Graded version: Which countries do we sell to?
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.
Graded version: Active engineers only
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.
Similar graded exercise: Headcount per department
Show the solution
SELECT tier, COUNT(*) AS customers
FROM Customers
GROUP BY tier
ORDER BY customers DESC, tier;
| 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?
Graded version: Every department, even the quiet ones
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.
Similar graded exercise: Only the multi-person departments
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.
Similar graded exercise: Put a name to the department
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.
Graded version: Better paid than average
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.
Graded version: Customers still waiting
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 returns no rows at all if the subquery produces a single NULL. That happens in every major database, including the SQLite that runs this site’s playground, and the WHERE clause guide shows it happening.
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.
Similar graded exercise: Label the price bands
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.
Similar graded exercise: One-row status report
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.
Similar graded exercise: Orders by month
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.
YEAR() and MONTH() are MySQL and SQL Server functions. SQLite has neither, so this site’s playground adds them for convenience; in plain SQLite you would write CAST(strftime('%Y', order_date) AS INTEGER), and in PostgreSQL EXTRACT(YEAR FROM order_date). The dialect differences guide lists the other spellings that change.
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.
Similar graded exercise: Who is closing the deals?
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. Two spellings here are not universal: MySQL writes the name as CONCAT(e.first_name, ' ', e.last_name), because by default its || means OR, and SQL Server uses + or CONCAT() and puts TOP 1 after SELECT instead of LIMIT 1 at the end.
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.
Graded version: Well-paid departments
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. The threshold does not have to be a literal: replace 100000 with (SELECT AVG(salary) FROM Employees) and the query finds the departments above the company-wide average instead, which in the sample data is still just Finance.
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.
Similar graded exercise: Category revenue report
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 are written for SQLite, the database that runs this site's playground, and most of the SQL is standard enough to run unchanged on PostgreSQL, MySQL and SQL Server. The main spellings that differ are YEAR() and MONTH() in problem 12, which are MySQL and SQL Server functions the playground adds to SQLite (PostgreSQL uses EXTRACT, plain SQLite uses strftime); LIMIT in problem 13, which SQL Server writes as TOP; and the || concatenation in problem 13, which is CONCAT() in MySQL and + or CONCAT() in SQL Server. The CTEs in problems 14 and 15 need MySQL 8.0 or later. 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: Core Syntax, Joins and Functions 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.