Practice

SQL Exercises with Solutions: 15 Practice Problems

12 min readUpdated September 10, 2026Every example verified
On this page 5 sections ▾
  1. The sample database
  2. The problems
  3. What to do next
  4. Frequently asked questions
  5. Related reading

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.

The sample database (5 tables)
TableRowsColumns
Departments5dept_id, dept_name, location, budget
Employees12emp_id, first_name, last_name, email, dept_id, manager_id, hire_date, salary, active
Customers8customer_id, company, contact, country, city, signup_date, tier
Products7product_id, name, category, unit_price, in_stock
Orders15order_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

Easy

List 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

Easy

Which 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

Easy

Find 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

Easy

How 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;
tiercustomers
Gold3
Platinum2
Silver2
Bronze1

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

Medium

Show 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_nameheadcount
Engineering5
Sales4
Finance2
Marketing1
Support0

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

Medium

What 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_nameavg_salaryheadcount
Finance1240002
Sales1000004
Engineering992005

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

Medium

List 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

Medium

Which 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

Medium

Which 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;
companycountry
Baltic SystemsPeru
Kilimanjaro FoodsTanzania
Sakura LogisticsJapan

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

Medium

Label 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

Medium

Produce 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_ordersshippedcancelled
15102

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

Medium

Show 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

Hard

Which 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

Hard

Using 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_nameavg_salary
Finance124000

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

Hard

Build 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:

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.

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.