SQLMentor // practice

SQL Practice Exercises with Answers — on a Live HR Schema

30 hands-on SQL exercises with full solutions, split into beginner, intermediate, and advanced sets. Every task runs against the classic HR schema, and every solution has a one-click "run in editor" link that executes it live in SQLMentor's free in-browser SQL editor — no install, no signup.

The right way to use this page: open the editor in another tab, attempt each task yourself, then expand Show solution to compare. Several of the advanced exercises are classic interview questions — if that is your goal, pair this page with the interview question sets.

The HR schema you are querying

Seven tables, linked by foreign keys — the same sample schema Oracle uses in its documentation and certification material:

The editor runs SQLite, so the solutions use portable standard SQL; where Oracle differs (date functions, FETCH FIRST), the solution notes say so.

Beginner — 10 exercises

Single-table queries: SELECT, WHERE, ORDER BY, LIKE, NULL handling, and simple aggregates. If any of these feel unfamiliar, the basic SQL and filtering topics cover the theory.

Beginner · Q1 — List employees

Show the first name, last name, and salary of the first 10 employees.

Show solution
SELECT first_name, last_name, salary
FROM employees
LIMIT 10;

A plain projection — pick only the columns you need instead of SELECT *.

Run this solution in the editor →

Beginner · Q2 — Filter by department

Show the names of all employees who work in department 60 (IT).

Show solution
SELECT first_name, last_name
FROM employees
WHERE department_id = 60;

WHERE filters rows before anything else happens to them.

Run this solution in the editor →

Beginner · Q3 — High earners

List employees earning more than 10,000, highest paid first.

Show solution
SELECT first_name, last_name, salary
FROM employees
WHERE salary > 10000
ORDER BY salary DESC;

ORDER BY ... DESC sorts the filtered result, not the whole table.

Run this solution in the editor →

Beginner · Q4 — Distinct jobs

Show every distinct job ID that appears in the employees table.

Show solution
SELECT DISTINCT job_id
FROM employees
ORDER BY job_id;

DISTINCT collapses duplicate values after the rows are selected.

Run this solution in the editor →

Beginner · Q5 — Hired after 1997

List employees hired on or after 1 January 1998, earliest first.

Show solution
SELECT first_name, last_name, hire_date
FROM employees
WHERE hire_date >= '1998-01-01'
ORDER BY hire_date;

ISO-formatted dates (YYYY-MM-DD) compare correctly as text — one reason the format is a standard.

Run this solution in the editor →

Beginner · Q6 — Pattern matching

Find every employee whose last name starts with the letter K.

Show solution
SELECT first_name, last_name
FROM employees
WHERE last_name LIKE 'K%';

% matches any run of characters; _ would match exactly one.

Run this solution in the editor →

Beginner · Q7 — Count per department

Count how many employees each department has, largest first.

Show solution
SELECT department_id, COUNT(*) AS emp_count
FROM employees
GROUP BY department_id
ORDER BY emp_count DESC;

Every column in the SELECT that isn't aggregated must appear in GROUP BY.

Run this solution in the editor →

Beginner · Q8 — NULL handling

List employees who have no commission (commission_pct is NULL).

Show solution
SELECT first_name, last_name, salary
FROM employees
WHERE commission_pct IS NULL;

= NULL never matches — NULL comparisons need IS NULL / IS NOT NULL.

Run this solution in the editor →

Beginner · Q9 — Concatenation & aliases

Show each employee's full name in one column called full_name.

Show solution
SELECT first_name || ' ' || last_name AS full_name, salary
FROM employees
ORDER BY full_name;

|| is the standard SQL concatenation operator (Oracle, PostgreSQL, SQLite, DB2).

Run this solution in the editor →

Beginner · Q10 — Company-wide aggregates

Show the company's minimum, maximum, and average salary (rounded).

Show solution
SELECT MIN(salary) AS min_sal,
       MAX(salary) AS max_sal,
       ROUND(AVG(salary), 2) AS avg_sal
FROM employees;

Aggregates without GROUP BY collapse the whole table into one row — and they skip NULLs.

Run this solution in the editor →

Intermediate — 10 exercises

Joins, GROUP BY with HAVING, subqueries, and the classic anti-join and self-join patterns. Backed by the joins and subqueries topics.

Intermediate · Q1 — Basic join

Show each employee's name together with their department name.

Show solution
SELECT e.first_name, e.last_name, d.department_name
FROM employees e
JOIN departments d ON e.department_id = d.department_id
ORDER BY d.department_name, e.last_name;

An inner join drops employees with a NULL department_id — use LEFT JOIN to keep them.

Run this solution in the editor →

Intermediate · Q2 — Three-table join

Show each employee's name, job title, and the city they work in.

Show solution
SELECT e.first_name, e.last_name, j.job_title, l.city
FROM employees e
JOIN jobs j ON e.job_id = j.job_id
JOIN departments d ON e.department_id = d.department_id
JOIN locations l ON d.location_id = l.location_id
ORDER BY l.city, e.last_name;

Chain joins one relationship at a time: employee → job, employee → department → location.

Run this solution in the editor →

Intermediate · Q3 — GROUP BY + HAVING

List the departments that have more than 5 employees.

Show solution
SELECT d.department_name, COUNT(*) AS emp_count
FROM employees e
JOIN departments d ON e.department_id = d.department_id
GROUP BY d.department_name
HAVING COUNT(*) > 5
ORDER BY emp_count DESC;

WHERE filters rows before grouping; HAVING filters the groups after.

Run this solution in the editor →

Intermediate · Q4 — Subquery vs the average

List employees who earn more than the company-wide average salary.

Show solution
SELECT first_name, last_name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees)
ORDER BY salary DESC;

The scalar subquery runs once; its single value feeds the outer comparison.

Run this solution in the editor →

Intermediate · Q5 — Anti-join

Find the departments that currently have no employees.

Show solution
SELECT d.department_id, d.department_name
FROM departments d
LEFT JOIN employees e ON e.department_id = d.department_id
WHERE e.employee_id IS NULL
ORDER BY d.department_name;

LEFT JOIN + IS NULL keeps only the rows with no match — the classic anti-join. NOT EXISTS does the same job.

Run this solution in the editor →

Intermediate · Q6 — Self join

Show each employee alongside their manager's full name.

Show solution
SELECT e.first_name || ' ' || e.last_name AS employee,
       m.first_name || ' ' || m.last_name AS manager
FROM employees e
JOIN employees m ON e.manager_id = m.employee_id
ORDER BY manager, employee;

Joining a table to itself just needs two aliases. The president (no manager) disappears — a LEFT JOIN would keep them.

Run this solution in the editor →

Intermediate · Q7 — Aggregate per job

Show each job title's average salary, but only for jobs averaging above 8,000.

Show solution
SELECT j.job_title, ROUND(AVG(e.salary), 2) AS avg_salary
FROM employees e
JOIN jobs j ON e.job_id = j.job_id
GROUP BY j.job_title
HAVING AVG(e.salary) > 8000
ORDER BY avg_salary DESC;

You can aggregate joined columns like any other; HAVING can reference the aggregate directly.

Run this solution in the editor →

Intermediate · Q8 — Semi-join with EXISTS

List employees who appear in the job history table (they changed jobs at least once).

Show solution
SELECT e.first_name, e.last_name
FROM employees e
WHERE EXISTS (
  SELECT 1 FROM job_history jh
  WHERE jh.employee_id = e.employee_id
)
ORDER BY e.last_name;

EXISTS stops at the first match — often faster than IN on large sets, and NULL-safe unlike NOT IN.

Run this solution in the editor →

Intermediate · Q9 — Correlated subquery

Find the highest-paid employee in each department.

Show solution
SELECT e.department_id, e.first_name, e.last_name, e.salary
FROM employees e
WHERE e.salary = (
  SELECT MAX(e2.salary)
  FROM employees e2
  WHERE e2.department_id = e.department_id
)
ORDER BY e.department_id;

The inner query re-runs per outer row, correlated on department_id. Window functions solve the same problem — see the advanced set.

Run this solution in the editor →

Intermediate · Q10 — Join through four tables

Count how many employees work in each country.

Show solution
SELECT c.country_name, COUNT(e.employee_id) AS emp_count
FROM employees e
JOIN departments d ON e.department_id = d.department_id
JOIN locations l ON d.location_id = l.location_id
JOIN countries c ON l.country_id = c.country_id
GROUP BY c.country_name
ORDER BY emp_count DESC;

Long join chains are normal in normalised schemas — follow the foreign keys one hop at a time.

Run this solution in the editor →

Advanced — 10 exercises

Interview territory: second-highest salary, top-N per group, running totals, and window functions. The window functions topic explains the machinery.

Advanced · Q1 — Second-highest salary

Find the second-highest salary in the company (the classic interview question).

Show solution
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

MAX below the MAX. Ties are handled naturally because both MAXes work on values, not rows.

Run this solution in the editor →

Advanced · Q2 — Nth-highest salary

Find the third-highest distinct salary using LIMIT/OFFSET.

Show solution
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 2;

OFFSET n-1 skips the top n−1 distinct values. In Oracle you'd use FETCH FIRST ... ROWS ONLY with OFFSET.

Run this solution in the editor →

Advanced · Q3 — Rank within department

Rank employees by salary within their department using a window function.

Show solution
SELECT department_id, first_name, last_name, salary,
       RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dept_rank
FROM employees
WHERE department_id IS NOT NULL
ORDER BY department_id, dept_rank;

PARTITION BY restarts the ranking per department; RANK leaves gaps after ties, DENSE_RANK doesn't.

Run this solution in the editor →

Advanced · Q4 — Top 3 per department

Show the top 3 earners in each department.

Show solution
SELECT department_id, first_name, last_name, salary
FROM (
  SELECT department_id, first_name, last_name, salary,
         ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn
  FROM employees
  WHERE department_id IS NOT NULL
)
WHERE rn <= 3
ORDER BY department_id, salary DESC;

Window functions can't be filtered in WHERE directly — wrap them in a subquery (or CTE) and filter the alias.

Run this solution in the editor →

Advanced · Q5 — Running total

Show a running total of salaries in hire-date order.

Show solution
SELECT first_name, last_name, hire_date, salary,
       SUM(salary) OVER (ORDER BY hire_date, employee_id) AS running_total
FROM employees
ORDER BY hire_date, employee_id;

SUM with ORDER BY inside OVER accumulates row by row. The employee_id tie-breaker makes the order deterministic.

Run this solution in the editor →

Advanced · Q6 — Gap to department average

Show each employee's salary next to their department's average and the difference.

Show solution
SELECT first_name, last_name, department_id, salary,
       ROUND(AVG(salary) OVER (PARTITION BY department_id), 2) AS dept_avg,
       ROUND(salary - AVG(salary) OVER (PARTITION BY department_id), 2) AS diff
FROM employees
WHERE department_id IS NOT NULL
ORDER BY department_id, diff DESC;

Unlike GROUP BY, a window aggregate keeps every row — perfect for row-vs-group comparisons.

Run this solution in the editor →

Advanced · Q7 — Find duplicate values

Find the salary amounts that more than one employee earns.

Show solution
SELECT salary, COUNT(*) AS how_many
FROM employees
GROUP BY salary
HAVING COUNT(*) > 1
ORDER BY how_many DESC, salary DESC;

GROUP BY + HAVING COUNT(*) > 1 is the standard duplicate-detection pattern for any column.

Run this solution in the editor →

Advanced · Q8 — Hires per year

Count how many employees were hired in each year.

Show solution
SELECT strftime('%Y', hire_date) AS hire_year, COUNT(*) AS hires
FROM employees
GROUP BY hire_year
ORDER BY hire_year;

This editor runs SQLite, where strftime('%Y', ...) extracts the year; in Oracle you'd write EXTRACT(YEAR FROM hire_date) or TO_CHAR.

Run this solution in the editor →

Advanced · Q9 — Percent of total

Show each department's total salary bill as a percentage of the company total.

Show solution
SELECT d.department_name,
       SUM(e.salary) AS dept_total,
       ROUND(100.0 * SUM(e.salary) / SUM(SUM(e.salary)) OVER (), 2) AS pct_of_company
FROM employees e
JOIN departments d ON e.department_id = d.department_id
GROUP BY d.department_name
ORDER BY pct_of_company DESC;

A window function over an aggregate — SUM(SUM(...)) OVER () — turns group totals into shares of the grand total.

Run this solution in the editor →

Advanced · Q10 — LAG comparison

For each employee (by hire date), show the previous hire's salary and the difference.

Show solution
SELECT first_name, last_name, hire_date, salary,
       LAG(salary) OVER (ORDER BY hire_date, employee_id) AS prev_salary,
       salary - LAG(salary) OVER (ORDER BY hire_date, employee_id) AS diff
FROM employees
ORDER BY hire_date, employee_id;

LAG reaches back one row in the window order; the first row has no predecessor, so it returns NULL.

Run this solution in the editor →

Want the reasoning behind a pattern, not just the answer? The SQL articles cover the comparisons that trip people up most — IN vs EXISTS, WHERE vs HAVING, and NULL handling among them.

Want a blank canvas?

Open the free in-browser SQL editor and write your own queries against the same live HR schema — results in under a second, no signup.

Open the SQL editor →

Frequently asked questions — SQL practice

Are these SQL practice exercises really free?
Yes. All 30 exercises, their solutions, and the in-browser SQL editor are completely free with no sign-up, no email gate, and no ads. The editor runs your queries against a live HR schema.
What is the HR schema?
The HR (Human Resources) schema is the classic sample database used in Oracle tutorials and certification prep: employees, departments, jobs, job history, locations, countries, and regions, linked by foreign keys. It is small enough to understand quickly but rich enough for joins, subqueries, and window functions.
Which SQL dialect do the exercises use?
The solutions run on the site's editor, which uses SQLite — so they stick to portable, standard SQL (joins, GROUP BY, subqueries, window functions, || concatenation). Where a dialect differs, the solution notes call it out, for example SQLite's strftime versus Oracle's EXTRACT or TO_CHAR.
How should I work through the exercises?
Try each task in the editor before opening the solution: read the task, write your own query, run it, and only then compare with the provided answer. The sets are ordered — finish the beginner set before moving on, and expect the advanced set to need window functions.
Do the solutions work for interview preparation?
Yes — several exercises are classic interview questions (second-highest salary, duplicate detection, top-N per group, department averages). Pair them with the interview question sets on SQLMentor for the theory side.