SQLMentor // learn sql

Practice Questions

Work through these 15 questions using the Oracle HR schema available in the SQL editor. Questions progress from beginner to advanced — try each one before revealing the answer.

Schema Reference

-- Key tables and columns you'll use:
EMPLOYEES    (employee_id, first_name, last_name, email, hire_date,
              job_id, salary, commission_pct, manager_id, department_id)

DEPARTMENTS  (department_id, department_name, manager_id, location_id)

JOBS         (job_id, job_title, min_salary, max_salary)

LOCATIONS    (location_id, street_address, postal_code, city, state_province, country_id)

COUNTRIES    (country_id, country_name, region_id)

REGIONS      (region_id, region_name)

JOB_HISTORY  (employee_id, start_date, end_date, job_id, department_id)

Foundations (Q1–Q3)

Q1 · Basic SELECT & Sorting
List every employee's full name (as a single column labelled full_name), their job ID, and their monthly salary. Show only employees who earn between $5,000 and $12,000 per month (inclusive). Sort the results from highest salary to lowest, then alphabetically by last name for ties.
▶ Show answer
SELECT first_name || ' ' || last_name AS full_name,
       job_id,
       salary
FROM   employees
WHERE  salary BETWEEN 5000 AND 12000    -- inclusive range filter
ORDER BY salary DESC,                   -- primary sort: highest salary first
         last_name ASC;                 -- secondary sort: alphabetical for same salary

Explanation: The || operator concatenates strings in Oracle. BETWEEN is inclusive (equivalent to >= 5000 AND <= 12000). Multiple ORDER BY columns are listed with commas; each can independently be ASC or DESC.

Expected rows: Around 38 employees (depending on HR data version).


Q2 · Filtering with Multiple Conditions
Find all employees who: - Work in department 50 OR department 80, AND - Were hired after January 1st, 1997, AND - Either have no commission (NULL) OR earn a commission rate above 0.2

Show their employee ID, full name, department ID, hire date, and commission percentage. Sort by department, then hire date.

▶ Show answer
SELECT employee_id,
       first_name || ' ' || last_name AS full_name,
       department_id,
       hire_date,
       commission_pct
FROM   employees
WHERE  department_id IN (50, 80)                -- department filter
AND    hire_date > DATE '1997-01-01'            -- date filter
AND    (commission_pct IS NULL                  -- no commission
        OR commission_pct > 0.2)               -- OR high commission
ORDER BY department_id,
         hire_date;

Explanation: IN (50, 80) is shorthand for department_id = 50 OR department_id = 80. The parentheses around the last OR condition are important — without them, operator precedence would misparse the logic (AND binds tighter than OR). DATE '1997-01-01' is the Oracle date literal syntax.


Q3 · Pattern Matching & NULL Handling
List all employees whose last name starts with the letter 'S' or ends with the letters 'son' (case-insensitive). Include their full name, email, job ID, and salary. Also show a derived column called salary_grade that displays 'High' for salaries above $10,000, 'Medium' for $5,000–$10,000, and 'Low' for below $5,000. Exclude employees with a NULL department.
▶ Show answer
SELECT first_name || ' ' || last_name AS full_name,
       email,
       job_id,
       salary,
       CASE
           WHEN salary > 10000  THEN 'High'
           WHEN salary >= 5000  THEN 'Medium'
           ELSE                      'Low'
       END AS salary_grade
FROM   employees
WHERE  (UPPER(last_name) LIKE 'S%'           -- starts with S (case-insensitive)
        OR UPPER(last_name) LIKE '%SON')      -- ends with SON
AND    department_id IS NOT NULL              -- exclude NULL department
ORDER BY last_name;

Explanation: UPPER() makes the LIKE comparison case-insensitive. The % wildcard matches any sequence of characters. The CASE expression evaluates conditions in order; the first matching WHEN wins. IS NOT NULL is the correct way to test for NULL (never use != NULL).


Aggregates (Q4–Q6)

Q4 · GROUP BY with HAVING
For each department that has more than 5 employees, show: - The department ID - The number of employees - The average salary (rounded to 2 decimal places) - The minimum and maximum salary - The total salary payroll

Only include departments where the average salary is greater than $6,000. Sort by average salary descending.

▶ Show answer
SELECT department_id,
       COUNT(*)              AS num_employees,
       ROUND(AVG(salary), 2) AS avg_salary,
       MIN(salary)           AS min_salary,
       MAX(salary)           AS max_salary,
       SUM(salary)           AS total_payroll
FROM   employees
WHERE  department_id IS NOT NULL          -- exclude employees with no department
GROUP BY department_id
HAVING COUNT(*) > 5                       -- filter: more than 5 employees
AND    AVG(salary) > 6000                 -- filter: average salary above 6000
ORDER BY avg_salary DESC;

Explanation: WHERE filters individual rows before grouping; HAVING filters groups after aggregation. You cannot use aggregate functions in WHERE — that's what HAVING is for. COUNT(*) counts all rows in the group; COUNT(department_id) would skip NULLs.


Q5 · Multiple Aggregates with Conditional Counting
For each job ID that appears in the employees table, calculate: - Total number of employees with that job - Number of employees who earn above the job's maximum salary (they're over-grade!) - Number of employees who earn below the job's minimum salary (they're under-grade!) - The average salary for that job in the employees table

Join to the JOBS table to get the min/max salary bounds. Show only jobs where at least one employee is out of grade (over or under). Sort by the number of out-of-grade employees descending.

▶ Show answer
SELECT e.job_id,
       j.job_title,
       COUNT(*)                                               AS total_employees,
       COUNT(CASE WHEN e.salary > j.max_salary THEN 1 END)   AS over_grade,
       COUNT(CASE WHEN e.salary < j.min_salary THEN 1 END)   AS under_grade,
       ROUND(AVG(e.salary), 2)                               AS avg_salary,
       j.min_salary,
       j.max_salary
FROM   employees e
JOIN   jobs j ON e.job_id = j.job_id
GROUP BY e.job_id, j.job_title, j.min_salary, j.max_salary
HAVING COUNT(CASE WHEN e.salary > j.max_salary THEN 1 END) > 0
OR     COUNT(CASE WHEN e.salary < j.min_salary THEN 1 END) > 0
ORDER BY (COUNT(CASE WHEN e.salary > j.max_salary THEN 1 END) +
          COUNT(CASE WHEN e.salary < j.min_salary THEN 1 END)) DESC;

Explanation: COUNT(CASE WHEN condition THEN 1 END) is a powerful pattern for conditional counting. The CASE returns 1 when the condition is true and NULL otherwise — COUNT ignores NULLs, so it effectively counts only the matching rows. This avoids multiple subqueries.


Q6 · Aggregation with Date Functions
Analyze hiring trends by year. For each year that any employee was hired, show: - The year - Total employees hired that year - Average starting salary for that year's hires (rounded to nearest dollar) - The name of the month with the most hires that year (bonus challenge)

Sort by year ascending.

▶ Show answer
-- Main answer: hiring by year
SELECT EXTRACT(YEAR FROM hire_date)     AS hire_year,
       COUNT(*)                          AS employees_hired,
       ROUND(AVG(salary))                AS avg_starting_salary
FROM   employees
GROUP BY EXTRACT(YEAR FROM hire_date)
ORDER BY hire_year;

-- Bonus: most popular hire month per year (using subquery + window function)
WITH monthly_hires AS (
    SELECT EXTRACT(YEAR  FROM hire_date) AS hire_year,
           EXTRACT(MONTH FROM hire_date) AS hire_month,
           TO_CHAR(hire_date, 'Month')   AS month_name,
           COUNT(*)                      AS cnt,
           RANK() OVER (
               PARTITION BY EXTRACT(YEAR FROM hire_date)
               ORDER BY COUNT(*) DESC
           ) AS rnk
    FROM   employees
    GROUP BY EXTRACT(YEAR FROM hire_date),
             EXTRACT(MONTH FROM hire_date),
             TO_CHAR(hire_date, 'Month')
)
SELECT hire_year, month_name, cnt AS peak_month_hires
FROM   monthly_hires
WHERE  rnk = 1
ORDER BY hire_year;

Explanation: EXTRACT(YEAR FROM hire_date) pulls the year component from the date. When grouping by a date expression, you must include the same expression in GROUP BY (not the alias). The bonus query uses a window function inside a CTE to find the peak month per year without a complex self-join.


Joins (Q7–Q9)

Q7 · Multi-Table JOIN
For every employee, show their full name, job title (from JOBS), department name (from DEPARTMENTS), and the city they work in (from LOCATIONS via DEPARTMENTS). Also show employees who do NOT have a department assigned — they should appear with NULL for department name and city. Sort by department name, then last name.
▶ Show answer
SELECT e.first_name || ' ' || e.last_name AS full_name,
       j.job_title,
       d.department_name,
       l.city
FROM   employees   e
JOIN   jobs        j ON e.job_id        = j.job_id          -- every employee has a job
LEFT JOIN departments d ON e.department_id = d.department_id -- some employees have no dept
LEFT JOIN locations   l ON d.location_id   = l.location_id  -- location via department
ORDER BY d.department_name NULLS LAST,                      -- NULLs at the end
         e.last_name;

Explanation: The first JOIN is an INNER JOIN — every employee must have a valid job. The subsequent JOINs are LEFT JOINs because employees may lack a department, and departments chain to locations. NULLS LAST puts rows with no department at the bottom of the sort.

Key insight: When you LEFT JOIN through a chain (employee → department → location), once a NULL appears (no department), all subsequent JOINs in the chain also produce NULL (no location). The chain of NULLs propagates automatically.


Q8 · LEFT JOIN to Find Non-Matches
Find all departments that currently have NO employees assigned to them. Show the department ID, department name, and the city of the department's location. Sort by department name.
▶ Show answer
SELECT d.department_id,
       d.department_name,
       l.city
FROM   departments d
LEFT JOIN employees  e ON d.department_id = e.department_id   -- include depts with no employees
LEFT JOIN locations  l ON d.location_id   = l.location_id
WHERE  e.employee_id IS NULL                                   -- filter: no employee matched
ORDER BY d.department_name;

Explanation: This is the classic "anti-join" pattern using LEFT JOIN + WHERE NULL. The LEFT JOIN keeps all departments. The WHERE e.employee_id IS NULL filter then keeps only those departments where no employee row matched — i.e., empty departments.

An alternative using NOT EXISTS:

SELECT d.department_id, d.department_name
FROM   departments d
WHERE  NOT EXISTS (
    SELECT 1 FROM employees e WHERE e.department_id = d.department_id
);

Both approaches return the same result; the NOT EXISTS version is often clearer to read.


Q9 · Three-Table JOIN with Aggregation
For each country, show: - The country name - The region name (from REGIONS) - The number of distinct cities in that country that have a department - The total number of employees in that country (across all departments in all its cities) - The average employee salary for that country (rounded to 2 decimal places)

Only include countries that have at least one employee. Sort by total employees descending.

▶ Show answer
SELECT c.country_name,
       r.region_name,
       COUNT(DISTINCT l.city)    AS distinct_cities,
       COUNT(e.employee_id)      AS total_employees,
       ROUND(AVG(e.salary), 2)   AS avg_salary
FROM   countries   c
JOIN   regions     r ON c.region_id     = r.region_id
JOIN   locations   l ON l.country_id    = c.country_id
JOIN   departments d ON d.location_id   = l.location_id
JOIN   employees   e ON e.department_id = d.department_id
GROUP BY c.country_name, r.region_name
HAVING COUNT(e.employee_id) > 0        -- only countries with employees
ORDER BY total_employees DESC;

Explanation: The join chain flows: countries → regions (for region name) → locations (cities in each country) → departments (departments in each city) → employees (people in each department). COUNT(DISTINCT l.city) counts unique cities, not total location records. All INNER JOINs here are appropriate because we only want countries with all these relationships.


Subqueries (Q10–Q11)

Q10 · Correlated Subquery
Find all employees who earn more than the average salary of their own department (not the company average — each employee is compared to their specific department's average). Show the employee's full name, their salary, their department ID, and the department average salary. Sort by department, then by salary descending within each department.
▶ Show answer
SELECT e.first_name || ' ' || e.last_name AS full_name,
       e.salary,
       e.department_id,
       ROUND(
           (SELECT AVG(salary)
            FROM   employees
            WHERE  department_id = e.department_id),   -- correlated: uses outer e.department_id
           2
       ) AS dept_avg_salary
FROM   employees e
WHERE  e.salary > (
    SELECT AVG(salary)                                 -- correlated subquery in WHERE
    FROM   employees
    WHERE  department_id = e.department_id             -- references outer query's row
)
ORDER BY e.department_id,
         e.salary DESC;

Alternative using window function (often more efficient):

SELECT full_name, salary, department_id, dept_avg_salary
FROM (
    SELECT first_name || ' ' || last_name              AS full_name,
           salary,
           department_id,
           ROUND(AVG(salary) OVER (PARTITION BY department_id), 2) AS dept_avg_salary
    FROM   employees
)
WHERE salary > dept_avg_salary
ORDER BY department_id, salary DESC;

Explanation: The correlated subquery re-executes for each employee, using that employee's department_id to compute the average for their department. The window function approach computes all department averages in one pass — generally faster for large tables.


Q11 · EXISTS Subquery
Find all employees who have EVER held a different job (i.e., they have at least one record in the JOB_HISTORY table). Show their employee ID, full name, current job title, current salary, and how many different jobs they've held historically (including their current one). Sort by the number of jobs held descending.
▶ Show answer
SELECT e.employee_id,
       e.first_name || ' ' || e.last_name AS full_name,
       j.job_title                        AS current_job,
       e.salary                           AS current_salary,
       1 + (                              -- 1 (current job) + count of historical jobs
           SELECT COUNT(*)
           FROM   job_history jh
           WHERE  jh.employee_id = e.employee_id
       ) AS total_jobs_held
FROM   employees e
JOIN   jobs      j ON e.job_id = j.job_id
WHERE  EXISTS (
    SELECT 1
    FROM   job_history jh
    WHERE  jh.employee_id = e.employee_id   -- only employees with history records
)
ORDER BY total_jobs_held DESC,
         e.last_name;

Explanation: EXISTS is used to filter to only employees who appear in JOB_HISTORY. It's more efficient than IN for large subqueries because it short-circuits at the first match. The 1 + (subquery) computes total jobs including the current one: their job_history count plus their current role.


Window Functions (Q12–Q13)

Q12 · Ranking Within Partitions
For each department, find the top 2 highest-paid employees. Show the department name, the employee's full name, their salary, and their rank within the department. If two employees are tied for a position, show both (and skip the next rank number). Sort by department name, then rank.
▶ Show answer
SELECT department_name,
       full_name,
       salary,
       salary_rank
FROM (
    SELECT d.department_name,
           e.first_name || ' ' || e.last_name        AS full_name,
           e.salary,
           RANK() OVER (
               PARTITION BY e.department_id
               ORDER BY e.salary DESC
           ) AS salary_rank
    FROM   employees   e
    JOIN   departments d ON e.department_id = d.department_id
)
WHERE  salary_rank <= 2                  -- top 2 per department (ties both included)
ORDER BY department_name,
         salary_rank;

Explanation: RANK() gives tied rows the same rank and then skips the next number (1, 2, 2, 4). This means WHERE salary_rank <= 2 correctly includes all employees tied for 2nd place. Using ROW_NUMBER() instead would arbitrarily exclude one tied employee. Using DENSE_RANK() would also work but might include 3 employees when two are tied for 2nd.

The window function runs in the inner query; the filter WHERE salary_rank <= 2 must be in an outer query (window function results are not visible in the same WHERE clause).


Q13 · Running Total
Show each employee's hire date, full name, salary, and a running total of salary costs ordered by hire date (i.e., the cumulative payroll as employees were hired over time). Also show what percentage of the total company payroll each employee's salary represents (rounded to 2 decimal places). Order by hire date, then employee_id for ties.
▶ Show answer
SELECT hire_date,
       first_name || ' ' || last_name              AS full_name,
       salary,
       SUM(salary) OVER (
           ORDER BY hire_date, employee_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       )                                           AS running_total_payroll,
       ROUND(
           100.0 * salary / SUM(salary) OVER (),   -- OVER() with no partition = grand total
           2
       )                                           AS pct_of_total_payroll
FROM   employees
ORDER BY hire_date, employee_id;

-- Sample of expected output:
-- HIRE_DATE  | FULL_NAME        | SALARY | RUNNING_TOTAL | PCT_OF_TOTAL
-- -----------|------------------|--------|---------------|-------------
-- 1987-09-17 | Jennifer Whalen  | 4400   | 4400          | 0.46
-- 1989-11-17 | Michael Hartstein| 13000  | 17400         | 1.35
-- 1990-01-03 | Pat Fay          | 6000   | 23400         | 0.62

Explanation: SUM(salary) OVER (ORDER BY hire_date, employee_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) creates a cumulative sum. ROWS BETWEEN is used (rather than RANGE BETWEEN) to handle ties in hire_date cleanly — ROWS counts physical rows while RANGE would add all tied dates at once. SUM(salary) OVER () with an empty OVER() computes the grand total once and applies it to every row.


Advanced (Q14–Q15)

Q14 · Recursive CTE — Management Hierarchy
Using a recursive CTE, display the entire management chain for all employees. Show each employee's full name, their depth in the hierarchy (CEO = level 0), the name of their direct manager, and the full chain from the CEO to that employee as a string (e.g., "Steven King > Neena Kochhar > Alexander Hunold"). Sort by the path string so the hierarchy is displayed in a readable tree order.
▶ Show answer
WITH emp_hierarchy (
    employee_id, full_name, manager_id, depth, manager_name, hierarchy_path
) AS (
    -- Anchor: start with the CEO (no manager)
    SELECT employee_id,
           first_name || ' ' || last_name AS full_name,
           manager_id,
           0                              AS depth,
           'No Manager'                   AS manager_name,
           first_name || ' ' || last_name AS hierarchy_path
    FROM   employees
    WHERE  manager_id IS NULL             -- top of the org chart

    UNION ALL

    -- Recursive: find each employee who reports to someone in the previous level
    SELECT e.employee_id,
           e.first_name || ' ' || e.last_name,
           e.manager_id,
           h.depth + 1,
           h.full_name,                  -- parent's name becomes manager_name
           h.hierarchy_path || ' > ' || e.first_name || ' ' || e.last_name
    FROM   employees e
    JOIN   emp_hierarchy h ON e.manager_id = h.employee_id  -- join to parent
)
SELECT LPAD(' ', depth * 3) || full_name AS employee_tree,
       depth                             AS org_level,
       manager_name,
       hierarchy_path
FROM   emp_hierarchy
ORDER BY hierarchy_path;

Explanation: The recursive CTE has two parts separated by UNION ALL. The anchor selects the root of the tree (the CEO with no manager). The recursive member joins each employee to their parent in the CTE result, increasing depth by 1 and extending the path string. The recursion terminates automatically when no more employees can be joined (i.e., when all leaves of the tree are reached). LPAD(' ', depth * 3) indents each level for visual hierarchy.


Q15 · Pivot-Style Conditional Aggregation
Create a salary distribution report that shows, for each department name, how many employees fall into each salary band: - Band 1: Below $5,000 - Band 2: $5,000 to $9,999 - Band 3: $10,000 to $14,999 - Band 4: $15,000 and above

Also show the department's total headcount and average salary. Only include departments with at least one employee. Sort by average salary descending.

▶ Show answer
SELECT d.department_name,
       COUNT(e.employee_id)                                         AS total_employees,
       ROUND(AVG(e.salary), 2)                                      AS avg_salary,
       COUNT(CASE WHEN e.salary < 5000                THEN 1 END)   AS band_1_under_5k,
       COUNT(CASE WHEN e.salary BETWEEN 5000 AND 9999 THEN 1 END)   AS band_2_5k_10k,
       COUNT(CASE WHEN e.salary BETWEEN 10000 AND 14999 THEN 1 END) AS band_3_10k_15k,
       COUNT(CASE WHEN e.salary >= 15000               THEN 1 END)  AS band_4_15k_plus
FROM   departments d
JOIN   employees   e ON d.department_id = e.department_id
GROUP BY d.department_id, d.department_name
HAVING COUNT(e.employee_id) >= 1
ORDER BY avg_salary DESC;

-- Sample output:
-- DEPARTMENT_NAME    | TOTAL | AVG_SALARY | BAND_1 | BAND_2 | BAND_3 | BAND_4
-- -------------------|-------|------------|--------|--------|--------|-------
-- Executive          | 3     | 19333.33   | 0      | 0      | 0      | 3
-- Sales              | 34    | 8955.88    | 0      | 30     | 2      | 2
-- Finance            | 6     | 8600.00    | 0      | 5      | 1      | 0
-- IT                 | 5     | 5760.00    | 0      | 5      | 0      | 0
-- Shipping           | 45    | 3475.56    | 38     | 7      | 0      | 0

Explanation: This is pivot-style aggregation — turning row values into columns using COUNT(CASE WHEN ...). Each band column counts employees whose salary falls in that range. The CASE expression returns 1 when the condition is true and NULL otherwise; COUNT skips NULLs, so it counts only the matching rows. This is far simpler than Oracle's PIVOT clause for a fixed set of bands.