SQL exercises with solutions
HOW TO USE THIS PAGE
- Pick a level, Warm-up, Practice or Challenge, then a question from the list.
- Write your query in the terminal and press Run (Ctrl+Enter; Tab completes a name, Ctrl+F tidies the layout). The schema shows the tables and their keys; its button enlarges it.
- The page checks every result: the same rows and values as the solution, whatever you name the columns. Order counts only when the question asks for one. A solved question gets a tick, kept in this browser.
- Stuck? Show solution opens the worked answer. A refused query says why and links to the rule it broke: the terminal follows PostgreSQL's rules.
- To work elsewhere, the bakery database page has the script that recreates the data in any SQLite tool (write
lower(name) LIKEthere instead ofILIKE).
HISTORY
Warm-up
1. The cheap shelf. List the name and price of every product that costs less than 2, cheapest first. (SELECT and WHERE, ORDER BY) · Expected: 5 rows.
Solution
SELECT name, price
FROM product
WHERE price < 2
ORDER BY price, id;
| name | price |
|---|---|
| Baguette | 1.2 |
| Croissant | 1.4 |
| Hot cross bun | 1.5 |
| Pain au chocolat | 1.6 |
| Apple turnover | 1.9 |
The id after price breaks ties. There are none here, but a second key costs nothing and keeps the order fixed if a new product ever shares a price.
2. Early customers. Which customers joined before June 2024, and from which town? Show the most recent first. (Dates and times) · Expected: 6 rows.
Solution
SELECT name, town, joined
FROM customer
WHERE joined < '2024-06-01'
ORDER BY joined DESC;
| name | town | joined |
|---|---|---|
| Farid Haddad | Old Town | 2024-05-19 |
| Emma Novak | Hillcrest | 2024-04-02 |
| David Chen | Old Town | 2024-03-11 |
| Chloe Martin | Riverside | 2024-02-20 |
| Ben Okafor | Hillcrest | 2024-02-03 |
| Alice Turner | Riverside | 2024-01-15 |
"Before June" means before the first of June, so the comparison is a strict < against '2024-06-01', written in the column's own ISO shape. joined <= '2024-05-31' gives the same rows here.
3. Every croissant. Find the products whose name contains the letters croissant anywhere, on their own or inside a longer word, whatever their case. (LIKE) · Expected: 2 rows.
Solution
SELECT id, name
FROM product
WHERE name ILIKE '%croissant%'
ORDER BY id;
| id | name |
|---|---|
| 4 | Croissant |
| 6 | Almond croissant |
LIKE '%croissant%' would miss the plain Croissant, whose capital C does not match a small c: LIKE is case-sensitive (see LIKE and ILIKE). The wildcard at the front is what finds the almond one.
4. Untidy emails. Some emails were typed with capital letters, or with a stray space at the start or the end. List the name and the email, exactly as stored, of every customer whose email is not written all in small letters with no space at either end. Customers with no email are left out. (LIKE, NULL) · Expected: 6 rows.
Solution
SELECT name, email
FROM customer
WHERE email <> lower(trim(email))
ORDER BY id;
| name | |
|---|---|
| Ben Okafor | Ben.Okafor@Mail.com |
| Emma Novak | emma.novak@post.net |
| Farid Haddad | FARID.HADDAD@MAIL.COM |
| Rosa Weber | Rosa.Weber@Post.Net |
| Tara Rossi | tara.rossi@webmail.org |
| Yusuf Eriksen | yusuf.eriksen@mail.com |
trim removes the spaces at both ends and lower the capitals, so a tidy email equals its own cleaned copy and an untidy one does not. A customer with no email has a NULL, and a comparison with NULL is never true, so those rows drop out without being asked: WHERE email IS NOT NULL would change nothing. Three LIKE tests (email <> lower(email) OR email LIKE ' %' OR email LIKE '% ') find the same six.
5. The counter by category. For each category, show how many products it has and the price of the dearest one. (GROUP BY) · Expected: 3 rows.
Solution
SELECT category, COUNT(*) AS products, MAX(price) AS dearest
FROM product
GROUP BY category
ORDER BY category;
| category | products | dearest |
|---|---|---|
| bread | 6 | 4.5 |
| cake | 7 | 4.1 |
| pastry | 7 | 2.6 |
Asking for the name of the dearest product is a different and harder question: name is neither grouped nor aggregated, so it cannot simply be added to this SELECT. Exercise 17 shows the pattern, with a window function.
Practice
6. Orders by town. How many orders did the customers of each town place? List only the towns with at least one order, busiest town first. (JOIN, GROUP BY) · Expected: 3 rows.
Solution
SELECT c.town, COUNT(*) AS orders
FROM orders AS o
JOIN customer AS c ON c.id = o.customer_id
GROUP BY c.town
ORDER BY orders DESC, c.town;
| town | orders |
|---|---|
| Old Town | 120 |
| Riverside | 109 |
| Hillcrest | 74 |
The town lives in customer and the orders in orders, so the join comes first and the grouping after it. Each row of the join is one order, which is why COUNT(*) counts orders.
7. Takings per day. For each day from Monday 23 to Sunday 29 March that had orders, how much did that day's order lines come to (each line's quantity times the price paid on it, refunds aside)? Round each day's total to two decimal places. (JOIN, GROUP BY) · Expected: 6 rows.
Solution
SELECT o.day, ROUND(SUM(oi.quantity * oi.unit_price), 2) AS takings
FROM orders AS o
JOIN order_item AS oi ON oi.order_id = o.id
WHERE o.day BETWEEN '2026-03-23' AND '2026-03-29'
GROUP BY o.day
ORDER BY o.day;
| day | takings |
|---|---|
| 2026-03-23 | 125.8 |
| 2026-03-24 | 100.7 |
| 2026-03-25 | 95.5 |
| 2026-03-26 | 125.5 |
| 2026-03-28 | 138.2 |
| 2026-03-29 | 159.0 |
The money of a line is its quantity times unit_price, the price paid on that line, so product is not needed. Friday 27 March is missing because the bakery is closed on Fridays and nobody ordered: a group needs rows. The CTE page builds a calendar that shows such a day with zero, and exercise 19 uses one.
8. The price rise. On some order lines, the price paid differs from the product's current price in the catalogue. Which products, and for each, the price paid on those lines and the current price? Give one row per product and price paid. (JOIN, SELECT and WHERE) · Expected: 3 rows.
Solution
SELECT DISTINCT p.name, oi.unit_price AS paid, p.price AS current_price
FROM order_item AS oi
JOIN product AS p ON p.id = oi.product_id
WHERE oi.unit_price <> p.price
ORDER BY p.name;
| name | paid | current_price |
|---|---|---|
| Cheesecake slice | 3.5 | 3.8 |
| Cinnamon roll | 2.4 | 2.6 |
| Focaccia | 3.4 | 3.6 |
Three products went up on Monday 16 March. product.price holds only today's price; the line keeps what the customer really paid, which is why takings are computed from unit_price: the catalogue price would overstate the first two weeks. DISTINCT folds the many lines sold at the old price into one row per product.
9. Busy hours. Take an order's hour as the first two characters of its time ('07' for 07:20). Which hours had at least 25 orders over the four weeks? Busiest first, and hours with the same count in clock order. (Dates and times, HAVING) · Expected: 6 rows.
Solution
SELECT substr(time, 1, 2) AS hour, COUNT(*) AS orders
FROM orders
GROUP BY hour
HAVING COUNT(*) >= 25
ORDER BY orders DESC, hour;
| hour | orders |
|---|---|
| 08 | 48 |
| 07 | 47 |
| 09 | 32 |
| 10 | 28 |
| 12 | 28 |
| 17 | 27 |
The hour is the first two characters of the time. Keeping it as text is fine for grouping and sorting, because every hour has two digits; a CAST would only be needed for arithmetic. Two hours share a count, which is why the question says how to order them, and why hour follows orders DESC in the ORDER BY.
10. Referred from afar. Among the customers who joined in 2026, list those who were referred by someone living in a different town, with both towns. (Self-joins) · Expected: 4 rows.
Solution
SELECT newcomer.name, newcomer.town, referrer.name AS referrer, referrer.town AS referrer_town
FROM customer AS newcomer
JOIN customer AS referrer ON referrer.id = newcomer.referred_by
WHERE newcomer.town <> referrer.town
AND newcomer.joined >= '2026-01-01'
ORDER BY newcomer.id;
| name | town | referrer | referrer_town |
|---|---|---|---|
| Marco Costa | Hillcrest | Chloe Martin | Riverside |
| Lena Schmidt | Old Town | Grace Lee | Riverside |
| Vera Tanaka | Old Town | Kofi Tanaka | Hillcrest |
| Quentin Jensen | Riverside | Hassan Ivanova | Old Town |
The same table plays two parts under two aliases. The inner join leaves out the customers who were not referred at all, which is right here: they have no referrer whose town could differ.
11. The staff room. List every member of staff with the name of their manager (left empty, NULL, for the owner, who has none) and the number of orders they served, 0 for anyone who served none. (LEFT JOIN, Self-joins) · Expected: 6 rows.
Solution
SELECT s.name, m.name AS manager, COUNT(o.id) AS served
FROM staff AS s
LEFT JOIN staff AS m ON m.id = s.manager_id
LEFT JOIN orders AS o ON o.staff_id = s.id
GROUP BY s.id, s.name, m.name
ORDER BY s.id;
| name | manager | served |
|---|---|---|
| Margaret Hale | NULL | 23 |
| Tom Becker | Margaret Hale | 9 |
| Nina Patel | Tom Becker | 91 |
| Omar Reyes | Tom Becker | 71 |
| Lucy Brandt | Tom Becker | 109 |
| Sam Ito | Tom Becker | 0 |
Both joins have to be LEFT: an inner join to the manager would drop the owner, and an inner join to the orders would drop the baker who never serves. COUNT(o.id) counts only the rows where an order was found, so the baker's row counts 0, where COUNT(*) would say 1.
12. Morning people. For each town, show the number of orders its customers placed before nine o'clock and the number placed at nine o'clock or later, side by side. (CASE WHEN) · Expected: 3 rows.
Solution
SELECT c.town,
SUM(CASE WHEN o.time < '09:00' THEN 1 ELSE 0 END) AS before_nine,
SUM(CASE WHEN o.time >= '09:00' THEN 1 ELSE 0 END) AS later
FROM customer AS c
JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.town
ORDER BY c.town;
| town | before_nine | later |
|---|---|---|
| Hillcrest | 22 | 52 |
| Old Town | 33 | 87 |
| Riverside | 40 | 69 |
Each CASE turns an order into a 1 or a 0, and the SUM counts the ones. Writing the second condition as >= '09:00' rather than > '09:00' matters: three orders were placed at exactly nine, and they belong in the later column, each counted once.
13. Above their category. Which products cost at least 0.50 more than the average price of their own category? Show that average too. (Subqueries, CTEs) · Expected: 6 rows.
Solution
WITH category_average AS (
SELECT category, AVG(price) AS average
FROM product
GROUP BY category
)
SELECT p.name, p.category, p.price, ROUND(a.average, 2) AS average
FROM product AS p
JOIN category_average AS a ON a.category = p.category
WHERE p.price >= a.average + 0.5
ORDER BY p.category, p.price DESC, p.id;
| name | category | price | average |
|---|---|---|---|
| Sourdough loaf | bread | 4.5 | 3.52 |
| Walnut bread | bread | 4.2 | 3.52 |
| Raspberry tart | cake | 4.1 | 3.29 |
| Lemon tart | cake | 3.9 | 3.29 |
| Cheesecake slice | cake | 3.8 | 3.29 |
| Cinnamon roll | pastry | 2.6 | 1.9 |
A correlated subquery in WHERE finds the same products, but showing the average as well would mean writing it twice. A CTE computes each category's average once and joins it like a table.
Solution
SELECT name
FROM product
EXCEPT
SELECT p.name
FROM product AS p
JOIN order_item AS oi ON oi.product_id = p.id
JOIN orders AS o ON o.id = oi.order_id
WHERE o.day IN ('2026-03-21', '2026-03-22')
ORDER BY name;
| name |
|---|
| Coffee eclair |
| Pistachio financier |
Every product, minus the ones sold that weekend. The first half reads product alone, so the pistachio financier, which nobody bought all month, is in the answer too; a join on its own could never show it. Both days are named: o.day >= '2026-03-21' would also take in the last weekend, and the coffee eclair, sold then, would drop out. Comparing names works because no two products share one; with a column that can repeat, compare the ids. Exercise 15 asks the same kind of question with NOT EXISTS.
Challenge
15. No bread, thank you. Which customers have never bought any bread? Include anyone who has never bought anything. (Subqueries) · Expected: 5 rows.
Solution
SELECT c.name
FROM customer AS c
WHERE NOT EXISTS (SELECT 1
FROM orders AS o
JOIN order_item AS oi ON oi.order_id = o.id
JOIN product AS p ON p.id = oi.product_id
WHERE o.customer_id = c.id
AND p.category = 'bread')
ORDER BY c.id;
| name |
|---|
| Hugo Silva |
| Marco Olsen |
| Greta Rossi |
| Marco Costa |
| Vera Tanaka |
The tempting answer joins everything and filters WHERE p.category <> 'bread', but that lists the customers who bought something that is not bread, which includes bread buyers who also took a croissant, and drops Hugo Silva and the others who bought nothing. The question is about the absence of a row, which is what NOT EXISTS asks.
16. Money back. For each day from Monday 16 to Sunday 22 March that had orders, show what that day's order lines came to (at the price paid), the refunds paid out that day (a refund counts on its own day, whatever the day of its order; 0 when there were none) and what was left. Round every amount to two decimal places. (CTEs, COALESCE) · Expected: 6 rows.
Solution
WITH sold AS (
SELECT o.day, SUM(oi.quantity * oi.unit_price) AS sold
FROM orders AS o
JOIN order_item AS oi ON oi.order_id = o.id
GROUP BY o.day
),
refunded AS (
SELECT day, SUM(amount) AS refunded
FROM refund
GROUP BY day
)
SELECT s.day,
ROUND(s.sold, 2) AS sold,
ROUND(COALESCE(r.refunded, 0), 2) AS refunded,
ROUND(s.sold - COALESCE(r.refunded, 0), 2) AS kept
FROM sold AS s
LEFT JOIN refunded AS r ON r.day = s.day
WHERE s.day BETWEEN '2026-03-16' AND '2026-03-22'
ORDER BY s.day;
| day | sold | refunded | kept |
|---|---|---|---|
| 2026-03-16 | 111.5 | 2.2 | 109.3 |
| 2026-03-17 | 105.5 | 1.2 | 104.3 |
| 2026-03-18 | 137.1 | 3.6 | 133.5 |
| 2026-03-19 | 158.1 | 3.9 | 154.2 |
| 2026-03-21 | 129.2 | 1.9 | 127.3 |
| 2026-03-22 | 128.6 | 0.0 | 128.6 |
Joining the lines and the refunds in one query multiplies them: every refund of a day is repeated on every line of that day, and its sum comes out many times too big. Adding up each table on its own first, one row per day in each CTE, and only then joining the two keeps both totals right. The join is a LEFT JOIN because Sunday 22 March had no refund, and COALESCE turns its missing total into 0, so the subtraction does not give NULL.
17. Best seller of each category. For each category, which of the products that sold at least once sold the most pieces over the four weeks? Keep ties, if there are any. (Window functions) · Expected: 4 rows.
Solution
WITH sold AS (
SELECT p.category, p.name, SUM(oi.quantity) AS pieces
FROM product AS p
JOIN order_item AS oi ON oi.product_id = p.id
GROUP BY p.id, p.category, p.name
),
ranked AS (
SELECT category, name, pieces,
RANK() OVER (PARTITION BY category ORDER BY pieces DESC) AS rk
FROM sold
)
SELECT category, name, pieces
FROM ranked
WHERE rk = 1
ORDER BY category, name;
| category | name | pieces |
|---|---|---|
| bread | Baguette | 87 |
| cake | Lemon tart | 58 |
| pastry | Cinnamon roll | 58 |
| pastry | Croissant | 58 |
Three steps: add up the pieces per product, rank the products within each category, keep the first. RANK gives tied products the same rank 1, so the two pastries that sold the same number both show; ROW_NUMBER would keep just one, chosen arbitrarily. The filter has to sit outside the CTE, because a window function cannot appear in WHERE.
18. Back again. Put each customer's orders in sequence by day, then time. For every order placed on Wednesday 4 March that is not its customer's first order, show the order's id and the day of that customer's previous order in the sequence. (Window functions, NULL) · Expected: 7 rows.
Solution
WITH visits AS (
SELECT id, customer_id, day, time,
LAG(day) OVER (PARTITION BY customer_id ORDER BY day, time) AS previous_day
FROM orders
)
SELECT id, customer_id, previous_day, day
FROM visits
WHERE day = '2026-03-04'
AND previous_day IS NOT NULL
ORDER BY customer_id, time;
| id | customer_id | previous_day | day |
|---|---|---|---|
| 24 | 1 | 2026-03-02 | 2026-03-04 |
| 27 | 1 | 2026-03-04 | 2026-03-04 |
| 32 | 5 | 2026-03-02 | 2026-03-04 |
| 33 | 5 | 2026-03-04 | 2026-03-04 |
| 30 | 6 | 2026-03-02 | 2026-03-04 |
| 28 | 12 | 2026-03-02 | 2026-03-04 |
| 29 | 28 | 2026-03-03 | 2026-03-04 |
A customer's first order has no previous row, so LAG returns NULL for it, and IS NOT NULL drops exactly those orders. The day is chosen outside the CTE, after LAG has seen every order: filtered inside it, the window would see only that Wednesday, and an order whose previous visit was on the Monday would look like a first. Ordering the window by day, time rather than day alone keeps two orders on the same day in their real order.
19. Shutters down. On which days from Monday 2 to Sunday 29 March 2026 did no customer from Hillcrest place an order? The dates you list cannot come from orders, so build them. (WITH RECURSIVE, Subqueries) · Expected: 6 rows.
Solution
WITH RECURSIVE calendar(n, day) AS (
SELECT 2, '2026-03-02'
UNION ALL
SELECT n + 1, '2026-03-' || RIGHT('0' || (n + 1), 2)
FROM calendar
WHERE n < 29
)
SELECT day
FROM calendar
WHERE day NOT IN (
SELECT o.day
FROM orders AS o
JOIN customer AS c ON c.id = o.customer_id
WHERE c.town = 'Hillcrest'
)
ORDER BY day;
| day |
|---|
| 2026-03-03 |
| 2026-03-06 |
| 2026-03-07 |
| 2026-03-13 |
| 2026-03-20 |
| 2026-03-27 |
A missing day has no row anywhere, so no query on orders alone can list it. The recursive CTE writes out every date of the period, one row per round, and NOT IN keeps those that no Hillcrest order carries: the four Fridays, when the shop is closed, and two more days, Tuesday 3 and Saturday 7 March, that only the calendar reveals. NOT IN is safe here because orders.day is never NULL; a LEFT JOIN with WHERE o.id IS NULL gives the same rows.
20. Word of mouth. Customers who walked in on their own (no referrer) brought others, who brought others in turn. How many customers does each walk-in account for, directly or down the chain, not counting the walk-in themself? A walk-in who brought nobody counts 0. (WITH RECURSIVE) · Expected: 7 rows.
Solution
WITH RECURSIVE chain(root_id, id) AS (
SELECT id, id FROM customer WHERE referred_by IS NULL
UNION ALL
SELECT ch.root_id, c.id
FROM chain AS ch
JOIN customer AS c ON c.referred_by = ch.id
)
SELECT r.name, COUNT(*) - 1 AS brought
FROM chain AS ch
JOIN customer AS r ON r.id = ch.root_id
GROUP BY r.id, r.name
ORDER BY brought DESC, r.id;
| name | brought |
|---|---|
| Alice Turner | 23 |
| Kofi Tanaka | 7 |
| Zoe Garcia | 3 |
| David Chen | 0 |
| Hugo Silva | 0 |
| Stefan Lambert | 0 |
| Iris Costa | 0 |
The anchor starts one chain per walk-in, carrying the walk-in's id as root_id; each round adds the customers referred by the newest members and passes the same root_id down. Every chain contains its walk-in too, hence the - 1. Because the walk-ins start their own chains, a walk-in who brought nobody still appears, with 0.

