SQL Practice — Beginner (Problems 1-30)
30 warm-up problems covering SELECT, WHERE, ORDER BY, LIMIT, DISTINCT, filtering operators, and basic aggregates — every one solved, run against real data, and explained.
Learning objectives
- Write correct SELECT/WHERE/ORDER BY/LIMIT queries confidently from a plain-English prompt.
- Use BETWEEN, IN, LIKE, and IS NULL correctly instead of reaching for error-prone equivalents.
- Apply COUNT/SUM/AVG/MIN/MAX with GROUP BY and HAVING, and know why HAVING exists at all.
- Read a real computed output table and connect it back to exactly which clause produced which value.
◆ Before you start
All 150 problems in this practice section run against the exact same 9-table schema, modeled after a small e-commerce company that also tracks internal projects and employee hours. Nothing here is abstract — every problem below was actually executed against a populated copy of this schema, so the SQL, the output table, and the explanation you'll see under each problem are all real, not hand-typed guesses.
9 tables are shared by every problem in this practice set — the exact schema:
departments — department_id (PK, INT), department_name (VARCHAR)
employees — employee_id (PK, INT), first_name, last_name, email (VARCHAR), department_id (FK → departments), manager_id (FK → employees), salary (DECIMAL), hire_date (DATE), city (VARCHAR)
customers — customer_id (PK, INT), customer_name, email (VARCHAR), city, country (VARCHAR), signup_date (DATE)
products — product_id (PK, INT), product_name, category (VARCHAR), price (DECIMAL), stock_quantity (INT)
orders — order_id (PK, INT), customer_id (FK → customers), employee_id (FK → employees, the sales employee), order_date (DATE), status (VARCHAR — Pending, Shipped, Delivered, Cancelled), total_amount (DECIMAL)
order_items — order_item_id (PK, INT), order_id (FK → orders), product_id (FK → products), quantity (INT), unit_price (DECIMAL — price at purchase time)
payments — payment_id (PK, INT), order_id (FK → orders), payment_date (DATE), amount (DECIMAL), payment_method (VARCHAR — Card, UPI, Cash, Netbanking), payment_status (VARCHAR — Success, Failed, Refunded)
projects — project_id (PK, INT), project_name (VARCHAR), start_date, end_date (DATE, nullable), budget (DECIMAL)
employee_projects — employee_id (FK → employees), project_id (FK → projects), hours_worked (DECIMAL)
▲ Note on realism
The sample data behind these problems deliberately isn't clean. A handful of orders have a corrupted total_amount, an invalid status, or reference a customer_id that doesn't exist — on purpose, so that Chapter 4's data-quality problems (P149, P110) have something real to find. If a query's output looks slightly messy in places, that's the dataset doing its job, not an error.
💻 Code example
-- No query for this page — this is the shared schema reference. -- See the next problem for the first real query.
P1 — Select All Employees · Beginner
Retrieve all columns from the employees table.
SQL
SELECT * FROM employees;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name | department_id | manager_id | salary | hire_date | city | |
|---|---|---|---|---|---|---|---|---|
| 1 | Arvind | Krishnan | arvind.krishnan@company.com | 1 | NULL | 285,000 | 2019-01-10 | Bengaluru |
| 2 | Meera | Nair | meera.nair@company.com | 1 | 1 | 215,000 | 2019-06-15 | Bengaluru |
| 3 | Karan | Malhotra | karan.malhotra@company.com | 2 | 1 | 198,000 | 2019-08-01 | Mumbai |
| 4 | Priya | Chopra | priya.chopra@company.com | 3 | 1 | 175,000 | 2020-02-20 | Mumbai |
| 5 | Suresh | Iyer | suresh.iyer@company.com | 4 | 1 | 160,000 | 2019-11-05 | Delhi |
| 6 | Anil | Bhatt | anil.bhatt@company.com | 5 | 1 | 205,000 | 2020-01-15 | Pune |
| 7 | Sunita | Rao | sunita.rao@company.com | 6 | 1 | 168,000 | 2020-03-10 | Hyderabad |
| 8 | Rohan | Verma | rohan.verma@company.com | 1 | 2 | 152,000 | 2020-05-12 | Bengaluru |
| 9 | Ananya | Singh | ananya.singh@company.com | 1 | 2 | 148,000 | 2020-07-19 | Pune |
| 10 | Vikram | Reddy | vikram.reddy@company.com | 2 | 3 | 131,000 | 2020-09-01 | Mumbai |
(showing 10 of 40 rows)
Explanation
SELECT * asks the database for every column it knows about on employees, in whatever order they were defined in the schema, for every row that exists — there's no WHERE clause, so nothing is filtered out. In this dataset that returns all 40 employees, from the CEO Arvind Krishnan down to the newest individual contributor. SELECT * is convenient while you're exploring a table for the first time, but in application code it's usually better to name columns explicitly (as the next problem does) — a SELECT * breaks silently if someone adds a column later and your code wasn't expecting it.
💻 Code example
SELECT * FROM employees;
P2 — Select Specific Employee Columns · Beginner
Display each employee's first name, last name, and salary.
SQL
SELECT first_name, last_name, salary FROM employees;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | salary |
|---|---|---|
| Arvind | Krishnan | 285,000 |
| Meera | Nair | 215,000 |
| Karan | Malhotra | 198,000 |
| Priya | Chopra | 175,000 |
| Suresh | Iyer | 160,000 |
| Anil | Bhatt | 205,000 |
| Sunita | Rao | 168,000 |
| Rohan | Verma | 152,000 |
| Ananya | Singh | 148,000 |
| Vikram | Reddy | 131,000 |
(showing 10 of 40 rows)
Explanation
Naming first_name, last_name, salary instead of * returns only the three columns the caller actually needs, in that exact order — the database doesn't have to read or serialize the email, department_id, manager_id, hire_date, or city columns for this query at all. The result set has the same 40 rows as Problem 1, just narrower. This is the pattern you want in real applications: it documents intent, and it means adding a new column to employees later can never silently change what this query returns.
💻 Code example
SELECT first_name, last_name, salary FROM employees;
P3 — Find Employees in Bengaluru · Beginner
Find all employees whose city is Bengaluru.
SQL
SELECT * FROM employees WHERE city = 'Bengaluru';
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name | department_id | manager_id | salary | hire_date | city | |
|---|---|---|---|---|---|---|---|---|
| 1 | Arvind | Krishnan | arvind.krishnan@company.com | 1 | NULL | 285,000 | 2019-01-10 | Bengaluru |
| 2 | Meera | Nair | meera.nair@company.com | 1 | 1 | 215,000 | 2019-06-15 | Bengaluru |
| 8 | Rohan | Verma | rohan.verma@company.com | 1 | 2 | 152,000 | 2020-05-12 | Bengaluru |
| 14 | Rohan | Rao | rohan.rao14@company.com | 1 | 8 | 167,000 | 2023-03-15 | Bengaluru |
| 22 | Arjun | Reddy | arjun.reddy22@company.com | 3 | 11 | 97,839.55 | 2022-09-16 | Bengaluru |
| 30 | Manish | Kumar | manish.kumar30@company.com | 5 | 12 | 48,719.11 | 2022-11-10 | Bengaluru |
| 38 | Riya | Chauhan | riya.chauhan38@company.com | 1 | 8 | 53,903.40 | 2024-04-18 | Bengaluru |
(7 rows total)
Explanation
The WHERE city = 'Bengaluru' clause is evaluated once per row, before anything is returned — a row survives only if its city column is exactly 'Bengaluru' (SQL string comparisons are case-sensitive by default). Of the 40 employees, 7 are based in Bengaluru, including the CEO Arvind Krishnan and his direct report Meera Nair. Because there's no index on city in this simple schema, the engine has to scan every row and test the predicate — on a real production table you'd add an index on city if this filter ran often.
💻 Code example
SELECT * FROM employees WHERE city = 'Bengaluru';
P4 — Employees With Salary Above 60000 · Beginner
Find employees earning more than 60,000.
SQL
SELECT first_name, last_name, salary FROM employees WHERE salary > 60000;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | salary |
|---|---|---|
| Arvind | Krishnan | 285,000 |
| Meera | Nair | 215,000 |
| Karan | Malhotra | 198,000 |
| Priya | Chopra | 175,000 |
| Suresh | Iyer | 160,000 |
| Anil | Bhatt | 205,000 |
| Sunita | Rao | 168,000 |
| Rohan | Verma | 152,000 |
| Ananya | Singh | 148,000 |
| Vikram | Reddy | 131,000 |
(showing 10 of 30 rows)
Explanation
salary > 60000 is a strict numeric inequality, so an employee earning exactly 60,000 would not qualify — only rows strictly above the threshold pass. 30 of the 40 employees clear that bar, which tells you most of the workforce (managers and above, plus a majority of the senior ICs) sits comfortably above ₹60k, while the newer, junior hires cluster below it.
💻 Code example
SELECT first_name, last_name, salary FROM employees WHERE salary > 60000;
P5 — Employees Hired After 2023 · Beginner
Find employees hired after January 1, 2023.
SQL
SELECT * FROM employees WHERE hire_date > '2023-01-01';
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name | department_id | manager_id | salary | hire_date | city | |
|---|---|---|---|---|---|---|---|---|
| 14 | Rohan | Rao | rohan.rao14@company.com | 1 | 8 | 167,000 | 2023-03-15 | Bengaluru |
| 15 | Vihaan | Thakur | vihaan.thakur15@company.com | 2 | 3 | 60,767.33 | 2023-11-11 | Mumbai |
| 17 | Rakesh | Shetty | rakesh.shetty17@company.com | 4 | 5 | 39,926.01 | 2024-07-09 | Pune |
| 18 | Sanjay | Arora | sanjay.arora18@company.com | 5 | 12 | 41,971.32 | 2024-04-21 | Hyderabad |
| 19 | Vikram | Rana | vikram.rana19@company.com | 6 | 7 | 67,953.88 | 2025-03-09 | Chennai |
| 20 | Deepak | Joshi | deepak.joshi20@company.com | 1 | 8 | 46,377.82 | 2023-12-19 | Kolkata |
| 21 | Diya | Kulkarni | diya.kulkarni21@company.com | 2 | 3 | 63,706.03 | 2024-06-08 | Ahmedabad |
| 24 | Rahul | Menon | rahul.menon24@company.com | 5 | 12 | 75,646.76 | 2024-10-03 | Delhi |
| 25 | Meera | Kapoor | meera.kapoor25@company.com | 6 | 7 | 61,086.04 | 2025-09-09 | Pune |
| 27 | Neha | Naidu | neha.naidu27@company.com | 2 | 3 | 44,873.10 | 2023-11-11 | Chennai |
(showing 10 of 18 rows)
Explanation
Dates in SQL can be compared with the same operators as numbers (>, <, >=, BETWEEN) once they're stored as a proper DATE type, and the string '2023-01-01' is implicitly cast to a date for the comparison. 18 employees were hired after that cutoff — this dataset's newer joiners, mostly individual contributors brought on through 2023-2025 as the company grew, while the founding leadership team (hired 2019-2020) is correctly excluded.
💻 Code example
SELECT * FROM employees WHERE hire_date > '2023-01-01';
P6 — Sort Employees by Salary · Beginner
Display employees from highest salary to lowest.
SQL
SELECT first_name, last_name, salary FROM employees ORDER BY salary DESC;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | salary |
|---|---|---|
| Arvind | Krishnan | 285,000 |
| Meera | Nair | 215,000 |
| Anil | Bhatt | 205,000 |
| Karan | Malhotra | 198,000 |
| Priya | Chopra | 175,000 |
| Sunita | Rao | 168,000 |
| Rohan | Rao | 167,000 |
| Suresh | Iyer | 160,000 |
| Rohan | Verma | 152,000 |
| Ananya | Singh | 148,000 |
(showing 10 of 40 rows)
Explanation
ORDER BY salary DESC sorts the full 40-row result set from the highest salary down, entirely independent of the SELECT list — you can sort by a column even without displaying every column used to sort. Arvind Krishnan (₹285,000, the CEO) leads, followed by Meera Nair and Anil Bhatt; sorting doesn't remove any rows, it only changes the order they're returned in.
💻 Code example
SELECT first_name, last_name, salary FROM employees ORDER BY salary DESC;
P7 — Top 5 Highest Paid Employees · Beginner
Return the five employees with the highest salaries.
SQL
SELECT first_name, last_name, salary FROM employees ORDER BY salary DESC LIMIT 5;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | salary |
|---|---|---|
| Arvind | Krishnan | 285,000 |
| Meera | Nair | 215,000 |
| Anil | Bhatt | 205,000 |
| Karan | Malhotra | 198,000 |
| Priya | Chopra | 175,000 |
(5 rows total)
Explanation
This combines the ORDER BY salary DESC from the previous problem with LIMIT 5, which is applied after sorting — so it keeps only the first five rows of the already-sorted result, not five arbitrary high earners. The result is exactly the five most senior salaries: Arvind (₹285,000), Meera (₹215,000), Anil Bhatt (₹205,000), Karan Malhotra (₹198,000), and the fifth-highest department head. Without the preceding ORDER BY, LIMIT would just return whichever five rows the engine happened to read first, which is not a meaningful answer.
💻 Code example
SELECT first_name, last_name, salary FROM employees ORDER BY salary DESC LIMIT 5;
P8 — Customers From India · Beginner
Find all customers whose country is India.
SQL
SELECT customer_id, customer_name, city FROM customers WHERE country = 'India';
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | city |
|---|---|---|
| 1 | Rahul Agarwal | Bengaluru |
| 2 | Sneha Nair | Mumbai |
| 3 | Amit Bhatia | Delhi |
| 4 | Pooja Sinha | Pune |
| 5 | Vikas Rana | Hyderabad |
| 6 | Neha Ghosh | Chennai |
| 7 | Rajesh Pandey | Kolkata |
| 8 | Swati Yadav | Ahmedabad |
| 9 | Manoj Kulkarni | Bengaluru |
| 10 | Kirti Shetty | Mumbai |
(showing 10 of 20 rows)
Explanation
The filter country = 'India' runs against the customers table this time, and returns 20 of the 30 customers — matching this dataset's deliberate mix of mostly-Indian customers with a handful of international ones (USA, UK, Canada, Germany, Australia, Singapore, UAE) sprinkled in for variety in later problems like P25's GROUP BY country.
💻 Code example
SELECT customer_id, customer_name, city FROM customers WHERE country = 'India';
P9 — Products Above ₹1000 · Beginner
Find products whose price is greater than 1000.
SQL
SELECT product_name, price FROM products WHERE price > 1000;
Output (computed against this section's live 9-table dataset)
| product_name | price |
|---|---|
| Mechanical Keyboard | 3,499 |
| 27-inch 4K Monitor | 24,999 |
| USB-C Hub | 1,299 |
| Noise Cancelling Headphones | 8,999 |
| Smartwatch | 12,999 |
| Portable SSD 1TB | 6,999 |
| Bluetooth Speaker | 2,299 |
| Men's Running Shoes | 3,299 |
| Dumbbell Set 10kg | 2,599 |
| Cricket Bat | 1,899 |
(showing 10 of 21 rows)
Explanation
price > 1000 filters the 28-row products table down to 21 products — everything except the handful of budget items (a ₹799 mouse, a ₹899 yoga mat, a couple of sub-₹1000 books and kitchen basics). The comparison works the same way for DECIMAL prices as it does for integer salaries in earlier problems.
💻 Code example
SELECT product_name, price FROM products WHERE price > 1000;
P10 — Delivered Orders · Beginner
Find all orders that have been delivered.
SQL
SELECT * FROM orders WHERE status = 'Delivered';
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | employee_id | order_date | status | total_amount |
|---|---|---|---|---|---|
| 1 | 7 | 10 | 2025-07-18 | Delivered | 13,490 |
| 2 | 5 | 3 | 2024-08-13 | Delivered | 7,797 |
| 4 | 19 | 21 | 2025-09-26 | Delivered | 12,293 |
| 5 | 8 | 3 | 2024-10-25 | Delivered | 14,792 |
| 6 | 21 | 27 | 2025-04-07 | Delivered | 115,286 |
| 10 | 2 | 3 | 2025-06-20 | Delivered | 76,385 |
| 11 | 7 | 15 | 2025-10-20 | Delivered | 35,991 |
| 12 | 6 | 39 | 2025-01-05 | Delivered | 4,995 |
| 13 | 17 | 39 | 2025-11-17 | Delivered | 10,388 |
| 14 | 6 | 21 | 2024-11-06 | Delivered | 9,095 |
(showing 10 of 88 rows)
Explanation
Matching status = 'Delivered' returns 88 of the 148 orders in the table — the largest single bucket, since the dataset was generated with roughly 55% of orders landing in Delivered, 15% Shipped, 10% Pending, and 20% Cancelled (deliberately high enough to make cancellation-rate and revenue-exclusion problems later on meaningful).
💻 Code example
SELECT * FROM orders WHERE status = 'Delivered';
P11 — Unique Customer Countries · Beginner
List every unique country represented in the customers table.
SQL
SELECT DISTINCT country FROM customers;
Output (computed against this section's live 9-table dataset)
| country |
|---|
| India |
| UAE |
| USA |
| UK |
| Canada |
| Australia |
| Germany |
| Singapore |
(8 rows total)
Explanation
DISTINCT runs after the rows are selected and collapses duplicate values in the country column down to one row per unique value — instead of returning all 30 customer rows, it returns just the 8 distinct countries represented among them (India, UAE, USA, UK, Canada, Germany, Australia, Singapore). It answers 'what values exist here', not 'how many rows have each value' — for that you'd add COUNT(*) and GROUP BY, as Problem 25 does.
💻 Code example
SELECT DISTINCT country FROM customers;
P12 — Products in Electronics · Beginner
Find all products in the Electronics category.
SQL
SELECT product_name, price FROM products WHERE category = 'Electronics';
Output (computed against this section's live 9-table dataset)
| product_name | price |
|---|---|
| Wireless Mouse | 799 |
| Mechanical Keyboard | 3,499 |
| 27-inch 4K Monitor | 24,999 |
| USB-C Hub | 1,299 |
| Noise Cancelling Headphones | 8,999 |
| Smartwatch | 12,999 |
| Portable SSD 1TB | 6,999 |
| Bluetooth Speaker | 2,299 |
(8 rows total)
Explanation
This is the same filter pattern as Problem 3 and Problem 9, just applied to the category column of products. Electronics is this dataset's largest category by design (8 of the 28 products), spanning the ₹799 wireless mouse up to the ₹24,999 4K monitor.
💻 Code example
SELECT product_name, price FROM products WHERE category = 'Electronics';
P13 — Salary Range · Beginner
Find employees earning between 40,000 and 80,000.
SQL
SELECT first_name, last_name, salary FROM employees WHERE salary BETWEEN 40000 AND 80000;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | salary |
|---|---|---|
| Vihaan | Thakur | 60,767.33 |
| Sanjay | Arora | 41,971.32 |
| Vikram | Rana | 67,953.88 |
| Deepak | Joshi | 46,377.82 |
| Diya | Kulkarni | 63,706.03 |
| Tanvi | Chatterjee | 43,454.56 |
| Rahul | Menon | 75,646.76 |
| Meera | Kapoor | 61,086.04 |
| Neha | Naidu | 44,873.10 |
| Priya | Yadav | 44,693.13 |
(showing 10 of 15 rows)
Explanation
BETWEEN 40000 AND 80000 is inclusive on both ends — DuckDB (and standard SQL) treats it as shorthand for salary >= 40000 AND salary <= 80000, so an employee earning exactly 40,000 or exactly 80,000 would still match. 15 employees fall in that band, mostly the individual contributors hired over the last two years, since the company's more senior managers and department heads all clear ₹100k+.
💻 Code example
SELECT first_name, last_name, salary FROM employees WHERE salary BETWEEN 40000 AND 80000;
P14 — Customers From Selected Cities · Beginner
Find customers from Bengaluru or Mumbai.
SQL
SELECT customer_name, city FROM customers WHERE city IN ('Bengaluru', 'Mumbai');
Output (computed against this section's live 9-table dataset)
| customer_name | city |
|---|---|
| Rahul Agarwal | Bengaluru |
| Sneha Nair | Mumbai |
| Manoj Kulkarni | Bengaluru |
| Kirti Shetty | Mumbai |
| Imran Khan | Bengaluru |
| Jyoti Das | Mumbai |
(6 rows total)
Explanation
IN ('Bengaluru', 'Mumbai') is exactly equivalent to city = 'Bengaluru' OR city = 'Mumbai', just more compact and, for longer lists, easier for the query planner to optimize. 6 customers are based in one of those two cities out of the 30 total.
💻 Code example
SELECT customer_name, city FROM customers WHERE city IN ('Bengaluru', 'Mumbai');
P15 — Names Starting With A · Beginner
Find customers whose names start with the letter A.
SQL
SELECT customer_name FROM customers WHERE customer_name LIKE 'A%';
Output (computed against this section's live 9-table dataset)
| customer_name |
|---|
| Amit Bhatia |
| Alok Bose |
(2 rows total)
Explanation
LIKE 'A%' is a pattern match: % stands for 'zero or more of any character', so this matches any customer_name that starts with the literal letter A (case-sensitive in DuckDB's default collation) — it would match 'Amit Bhatia' but not 'alok bose' if that were lowercase, and it would not match a name where 'A' appears in the middle. Only 2 of the 30 customers happen to start with A in this dataset: Amit Bhatia and Alok Bose.
💻 Code example
SELECT customer_name FROM customers WHERE customer_name LIKE 'A%';
P16 — Products With Low Stock · Beginner
Find products with fewer than 10 units in stock.
SQL
SELECT product_name, stock_quantity FROM products WHERE stock_quantity < 10;
Output (computed against this section's live 9-table dataset)
| product_name | stock_quantity |
|---|---|
| Smartwatch | 8 |
| Cycling Helmet | 9 |
(2 rows total)
Explanation
stock_quantity < 10 finds products at real risk of stocking out — a strict inequality, so a product sitting at exactly 10 units would not be flagged yet. Only 2 products qualify here: the Smartwatch (8 units) and the Cycling Helmet (9 units) — not coincidentally, these are also the two products this dataset deliberately keeps out of every order in Problem 35, since a slow-moving, low-stock item is a realistic reason a product might never sell.
💻 Code example
SELECT product_name, stock_quantity FROM products WHERE stock_quantity < 10;
P17 — Cancelled Orders · Beginner
Find all cancelled orders and display their IDs and amounts.
SQL
SELECT order_id, total_amount FROM orders WHERE status = 'Cancelled';
Output (computed against this section's live 9-table dataset)
| order_id | total_amount |
|---|---|
| 8 | 6,294 |
| 9 | 13,891 |
| 16 | 8,392 |
| 18 | 13,196 |
| 26 | 2,397 |
| 29 | 3,696 |
| 30 | 24,688 |
| 33 | 13,592 |
| 40 | 6,396 |
| 43 | 8,892 |
(showing 10 of 25 rows)
Explanation
Filtering on status = 'Cancelled' surfaces exactly the orders that never generated revenue — 25 of the 148 orders (roughly the 20% cancellation rate baked into this dataset), each shown with its total_amount even though that amount was never actually collected. This is the same set of rows Problem 24 and Problem 106 both have to explicitly exclude or measure.
💻 Code example
SELECT order_id, total_amount FROM orders WHERE status = 'Cancelled';
P18 — Orders Above 5000 · Beginner
Find orders whose total amount exceeds 5000.
SQL
SELECT order_id, customer_id, total_amount FROM orders WHERE total_amount > 5000;
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | total_amount |
|---|---|---|
| 1 | 7 | 13,490 |
| 2 | 5 | 7,797 |
| 3 | 9 | 9,092 |
| 4 | 19 | 12,293 |
| 5 | 8 | 14,792 |
| 6 | 21 | 115,286 |
| 7 | 1 | 14,494 |
| 8 | 8 | 6,294 |
| 9 | 2 | 13,891 |
| 10 | 2 | 76,385 |
(showing 10 of 119 rows)
Explanation
total_amount > 5000 is a simple numeric filter over orders, matching 119 of the 148 rows — most orders in this dataset clear ₹5,000 because each one bundles 1-4 line items (see order_items), so only the smallest single-item orders fall under that line.
💻 Code example
SELECT order_id, customer_id, total_amount FROM orders WHERE total_amount > 5000;
P19 — Employees in Two Departments · Beginner
Find employees belonging to department 1 or department 2.
SQL
SELECT employee_id, first_name, department_id FROM employees WHERE department_id IN (1, 2);
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | department_id |
|---|---|---|
| 1 | Arvind | 1 |
| 2 | Meera | 1 |
| 3 | Karan | 2 |
| 8 | Rohan | 1 |
| 9 | Ananya | 1 |
| 10 | Vikram | 2 |
| 14 | Rohan | 1 |
| 15 | Vihaan | 2 |
| 20 | Deepak | 1 |
| 21 | Diya | 2 |
(showing 10 of 16 rows)
Explanation
department_id IN (1, 2) is the numeric-column version of Problem 14's city filter — it matches employees in Engineering (department_id = 1) or Sales (department_id = 2), 16 employees combined, without needing two separate OR conditions.
💻 Code example
SELECT employee_id, first_name, department_id FROM employees WHERE department_id IN (1, 2);
P20 — Employees Without Managers · Beginner
Find employees who do not have a manager.
SQL
SELECT employee_id, first_name, last_name FROM employees WHERE manager_id IS NULL;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name |
|---|---|---|
| 1 | Arvind | Krishnan |
(1 row total)
Explanation
manager_id IS NULL is the only correct way to test for a missing value in SQL — manager_id = NULL would never match anything, because NULL represents 'unknown', and unknown = unknown itself evaluates to unknown (not true). Exactly one employee has no manager: Arvind Krishnan, the CEO — everyone else in the 40-person hierarchy ultimately reports up to him, which is exactly what makes the recursive-hierarchy problems in Chapter 4 (P126, P127) work.
💻 Code example
SELECT employee_id, first_name, last_name FROM employees WHERE manager_id IS NULL;
P21 — Count All Employees · Beginner
Find the total number of employees.
SQL
SELECT COUNT(*) AS employee_count FROM employees;
Output (computed against this section's live 9-table dataset)
| employee_count |
|---|
| 40 |
(1 row total)
Explanation
COUNT(*) counts rows, not values in a particular column, so it correctly returns 40 regardless of whether any individual column (like manager_id, which is NULL for the CEO) has missing data. This is the simplest possible aggregate query: one row in, one row out, summarizing the whole table.
💻 Code example
SELECT COUNT(*) AS employee_count FROM employees;
P22 — Average Employee Salary · Beginner
Calculate the average salary of all employees.
SQL
SELECT AVG(salary) AS average_salary FROM employees;
Output (computed against this section's live 9-table dataset)
| average_salary |
|---|
| 102,073.49 |
(1 row total)
Explanation
AVG(salary) sums every employee's salary and divides by the row count (40) in a single pass — it returns roughly ₹102,073, which sits well above the median individual-contributor salary because it's pulled upward by the CEO's ₹285,000 and the five-figure salaries of the department heads and managers. This is exactly why later problems (P44, P134) also compute the median — averages can be misleading when a dataset has a few very large outliers.
💻 Code example
SELECT AVG(salary) AS average_salary FROM employees;
P23 — Highest and Lowest Salary · Beginner
Find the maximum and minimum employee salary.
SQL
SELECT MAX(salary) AS highest_salary, MIN(salary) AS lowest_salary FROM employees;
Output (computed against this section's live 9-table dataset)
| highest_salary | lowest_salary |
|---|---|
| 285,000 | 38,194.72 |
(1 row total)
Explanation
MAX and MIN can be computed in the same SELECT because each is an independent aggregate over the same column — the engine only needs one pass over the table to produce both. The spread here is enormous: ₹285,000 (the CEO) down to roughly ₹38,195 (this dataset's most junior individual contributor), a nearly 7.5x gap that reflects the org's multi-level hierarchy from CEO down to ICs.
💻 Code example
SELECT MAX(salary) AS highest_salary, MIN(salary) AS lowest_salary FROM employees;
P24 — Total Order Revenue · Beginner
Calculate the total value of all orders.
SQL
SELECT SUM(total_amount) AS total_revenue FROM orders WHERE status <> 'Cancelled';
Output (computed against this section's live 9-table dataset)
| total_revenue |
|---|
| 2,705,025 |
(1 row total)
Explanation
SUM(total_amount) alone would double-count revenue that was never actually earned, since 25 of the 148 orders are Cancelled — the WHERE status <> 'Cancelled' clause runs before the aggregation, so those rows are excluded from the sum entirely. The result, roughly ₹2.7 million, is this dataset's real non-cancelled order revenue; contrast this with what you'd get by summing every row regardless of status, which would overstate revenue by the value of every cancelled order.
💻 Code example
SELECT SUM(total_amount) AS total_revenue FROM orders WHERE status <> 'Cancelled';
P25 — Count Customers by Country · Beginner
Show how many customers belong to each country.
SQL
SELECT country, COUNT(*) AS customer_count FROM customers GROUP BY country ORDER BY customer_count DESC;
Output (computed against this section's live 9-table dataset)
| country | customer_count |
|---|---|
| India | 20 |
| UK | 2 |
| USA | 2 |
| Canada | 2 |
| Australia | 1 |
| UAE | 1 |
| Singapore | 1 |
| Germany | 1 |
(8 rows total)
Explanation
GROUP BY country collapses the 30 customer rows into one row per distinct country, and COUNT(*) counts how many original rows fell into each group. India dominates with 20 customers, while the seven international countries have 1-2 each — ORDER BY customer_count DESC then puts the largest group first so the concentration is immediately visible.
💻 Code example
SELECT country, COUNT(*) AS customer_count FROM customers GROUP BY country ORDER BY customer_count DESC;
P26 — Average Salary by Department · Beginner
Calculate the average salary for each department.
SQL
SELECT department_id, AVG(salary) AS average_salary FROM employees GROUP BY department_id;
Output (computed against this section's live 9-table dataset)
| department_id | average_salary |
|---|---|
| 1 | 138,588.51 |
| 2 | 92,711.24 |
| 3 | 99,118.03 |
| 4 | 74,451.17 |
| 5 | 96,749.44 |
| 6 | 90,014.29 |
(6 rows total)
Explanation
Grouping by department_id and averaging salary within each group shows the org's pay structure department by department: Engineering averages the highest (₹138,589, pulled up by two senior heads plus several well-paid ICs), while HR sits lowest (₹74,451). Unlike Problem 22's single company-wide average, this reveals that the 'average salary' varies a lot depending on which team you're looking at.
💻 Code example
SELECT department_id, AVG(salary) AS average_salary FROM employees GROUP BY department_id;
P27 — Product Count by Category · Beginner
Count products in every category.
SQL
SELECT category, COUNT(*) AS product_count FROM products GROUP BY category ORDER BY product_count DESC;
Output (computed against this section's live 9-table dataset)
| category | product_count |
|---|---|
| Electronics | 8 |
| Sports | 6 |
| Home & Kitchen | 6 |
| Clothing | 4 |
| Books | 4 |
(5 rows total)
Explanation
Grouping products by category and counting rows per group shows the catalog's composition: Electronics leads with 8 products, Sports and Home & Kitchen follow with 6 each, and Clothing and Books round out the remaining 8. ORDER BY product_count DESC puts the best-stocked category first.
💻 Code example
SELECT category, COUNT(*) AS product_count FROM products GROUP BY category ORDER BY product_count DESC;
P28 — Departments With More Than 5 Employees · Beginner
Find departments having more than five employees.
SQL
SELECT department_id, COUNT(*) AS employee_count FROM employees GROUP BY department_id HAVING COUNT(*) > 5;
Output (computed against this section's live 9-table dataset)
| department_id | employee_count |
|---|---|
| 1 | 9 |
| 2 | 7 |
| 3 | 7 |
| 5 | 6 |
| 6 | 6 |
(5 rows total)
Explanation
HAVING is the equivalent of WHERE but for aggregated results — it can't run until after GROUP BY department_id and COUNT(*) have already produced one row per department, because COUNT(*) > 5 isn't a condition any single raw employee row could satisfy on its own. Five of this dataset's six departments clear that bar (only HR, the smallest team, falls at or under 5 employees), which is exactly why HAVING — not WHERE — has to be used here.
💻 Code example
SELECT department_id, COUNT(*) AS employee_count FROM employees GROUP BY department_id HAVING COUNT(*) > 5;
P29 — Categorize Employees by Salary · Beginner
Classify employees as High, Medium, or Low salary.
SQL
SELECT first_name, salary, CASE WHEN salary >= 80000 THEN 'High' WHEN salary >= 50000 THEN 'Medium' ELSE 'Low' END AS salary_level FROM employees;
Output (computed against this section's live 9-table dataset)
| first_name | salary | salary_level |
|---|---|---|
| Arvind | 285,000 | High |
| Meera | 215,000 | High |
| Karan | 198,000 | High |
| Priya | 175,000 | High |
| Suresh | 160,000 | High |
| Anil | 205,000 | High |
| Sunita | 168,000 | High |
| Rohan | 152,000 | High |
| Ananya | 148,000 | High |
| Vikram | 131,000 | High |
(showing 10 of 40 rows)
Explanation
CASE WHEN ... THEN ... ELSE ... END evaluates each employee's salary against the conditions in order and stops at the first one that matches — a salary of ₹198,000 hits the >= 80000 branch and is labeled 'High' before the engine ever checks the >= 50000 branch, and anyone under 50,000 falls through to 'Low'. Because the org's leadership salaries are so far above 80,000, a large share of this 40-row result comes back labeled 'High', with 'Medium' and 'Low' concentrated among the more recently hired individual contributors.
💻 Code example
SELECT first_name, salary, CASE WHEN salary >= 80000 THEN 'High' WHEN salary >= 50000 THEN 'Medium' ELSE 'Low' END AS salary_level FROM employees;
P30 — Basic Employee Department Join · Beginner
Display each employee's name and department name.
SQL
SELECT e.first_name, e.last_name, d.department_name FROM employees e JOIN departments d ON e.department_id = d.department_id;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | department_name |
|---|---|---|
| Arvind | Krishnan | Engineering |
| Meera | Nair | Engineering |
| Karan | Malhotra | Sales |
| Priya | Chopra | Marketing |
| Suresh | Iyer | Human Resources |
| Anil | Bhatt | Finance |
| Sunita | Rao | Operations |
| Rohan | Verma | Engineering |
| Ananya | Singh | Engineering |
| Vikram | Reddy | Sales |
(showing 10 of 40 rows)
Explanation
A plain JOIN (shorthand for INNER JOIN) matches each employees row to the departments row whose department_id equals the employee's own department_id — since every employee in this dataset has a valid department_id, all 40 employees appear, now with a human-readable department_name (like 'Engineering' or 'Sales') standing in for the raw numeric foreign key. This is the foundational join pattern every later multi-table problem in this section builds on.
💻 Code example
SELECT e.first_name, e.last_name, d.department_name FROM employees e JOIN departments d ON e.department_id = d.department_id;
Want a visual for this concept?
Generate a diagram tailored to “SQL Practice — Beginner (Problems 1-30)” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →