Skip to main content

Python SQL Fundamentals Practice Problems & Exercises

Practice: SQL Fundamentals

11 problems4 Easy4 Medium3 Hard50–70 min
← Back to lesson

#1Basic SELECT and WHEREEasy
SELECTWHEREsqlite3

Write a query that returns all employees in the Engineering department with a salary greater than 90000, ordered by salary descending.

Solution:

import sqlite3

conn = sqlite3.connect(':memory:')
conn.execute("""
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT,
department TEXT,
salary REAL
)
""")
conn.executemany("INSERT INTO employees VALUES (?,?,?,?)", [
(1, 'Alice', 'Engineering', 95000),
(2, 'Bob', 'Marketing', 72000),
(3, 'Carol', 'Engineering', 105000),
(4, 'Dave', 'HR', 68000),
(5, 'Eve', 'Engineering', 88000),
])
conn.commit()

def high_paid_engineers(conn):
cur = conn.execute("""
SELECT name, salary
FROM employees
WHERE department = 'Engineering' AND salary > 90000
ORDER BY salary DESC
""")
return cur.fetchall()

print(high_paid_engineers(conn))
# [('Carol', 105000.0), ('Alice', 95000.0)]
import sqlite3

conn = sqlite3.connect(':memory:')
conn.execute("""
CREATE TABLE employees (
  id INTEGER PRIMARY KEY,
  name TEXT,
  department TEXT,
  salary REAL
)
""")
conn.executemany("INSERT INTO employees VALUES (?,?,?,?)", [
  (1, 'Alice', 'Engineering', 95000),
  (2, 'Bob', 'Marketing', 72000),
  (3, 'Carol', 'Engineering', 105000),
  (4, 'Dave', 'HR', 68000),
  (5, 'Eve', 'Engineering', 88000),
])
conn.commit()

# TODO: Query all engineers with salary > 90000
# Return list of (name, salary) tuples sorted by salary DESC

def high_paid_engineers(conn):
  pass

print(high_paid_engineers(conn))
Expected Output
[('Carol', 105000.0), ('Alice', 95000.0)]
Hints

Hint 1: Use WHERE with two conditions joined by AND.

Hint 2: Use ORDER BY salary DESC to sort descending.

Hint 3: cursor.fetchall() returns a list of tuples.


#2COUNT and GROUP BYEasy
GROUP BYCOUNTaggregate

Using GROUP BY, compute the number of orders and total amount spent per customer, sorted by total spend descending.

Solution:

def customer_summary(conn):
cur = conn.execute("""
SELECT customer, COUNT(*) AS order_count, SUM(amount) AS total_spent
FROM orders
GROUP BY customer
ORDER BY total_spent DESC
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.execute("""
CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer TEXT,
  product TEXT,
  amount REAL
)
""")
conn.executemany("INSERT INTO orders VALUES (?,?,?,?)", [
  (1, 'Alice', 'Widget', 50.0),
  (2, 'Bob', 'Gadget', 120.0),
  (3, 'Alice', 'Gadget', 80.0),
  (4, 'Carol', 'Widget', 30.0),
  (5, 'Bob', 'Widget', 45.0),
  (6, 'Alice', 'Doohickey', 200.0),
])
conn.commit()

# TODO: Return (customer, order_count, total_spent) for each customer
# sorted by total_spent DESC

def customer_summary(conn):
  pass

for row in customer_summary(conn):
  print(row)
Expected Output
('Alice', 3, 330.0)
('Bob', 2, 165.0)
('Carol', 1, 30.0)
Hints

Hint 1: GROUP BY customer aggregates rows per customer.

Hint 2: COUNT(*) counts rows; SUM(amount) totals the amount.

Hint 3: ORDER BY the SUM alias or column position.


#3INNER JOIN Two TablesEasy
JOININNER JOINforeign key

Write an INNER JOIN query that returns each employee's name alongside their department name, sorted alphabetically by department then employee.

Solution:

def employee_departments(conn):
cur = conn.execute("""
SELECT e.name, d.name
FROM employees e
JOIN departments d ON e.dept_id = d.id
ORDER BY d.name, e.name
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE departments (
  id INTEGER PRIMARY KEY,
  name TEXT
);
CREATE TABLE employees (
  id INTEGER PRIMARY KEY,
  name TEXT,
  dept_id INTEGER REFERENCES departments(id)
);
INSERT INTO departments VALUES (1,'Engineering'),(2,'Marketing'),(3,'HR');
INSERT INTO employees VALUES
  (1,'Alice',1),(2,'Bob',2),(3,'Carol',1),(4,'Dave',3),(5,'Eve',1);
""")

# TODO: Return (employee_name, department_name) for all employees
# sorted by department_name, then employee_name

def employee_departments(conn):
  pass

for row in employee_departments(conn):
  print(row)
Expected Output
('Alice', 'Engineering')
('Carol', 'Engineering')
('Eve', 'Engineering')
('Dave', 'HR')
('Bob', 'Marketing')
Hints

Hint 1: JOIN employees e ON e.dept_id = d.id links the tables.

Hint 2: Select e.name and d.name in your SELECT clause.

Hint 3: ORDER BY d.name, e.name gives alphabetical ordering.


#4HAVING Filter on AggregatesEasy
HAVINGGROUP BYaggregate filter

Use HAVING to filter sales reps whose total sales exceed 1200, returning name and total sorted by total descending.

Solution:

def top_reps(conn):
cur = conn.execute("""
SELECT rep, SUM(amount) AS total_sales
FROM sales
GROUP BY rep
HAVING SUM(amount) > 1200
ORDER BY total_sales DESC
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.execute("CREATE TABLE sales (rep TEXT, amount REAL)")
conn.executemany("INSERT INTO sales VALUES (?,?)", [
  ('Alice', 500), ('Bob', 300), ('Alice', 700),
  ('Carol', 200), ('Bob', 900), ('Carol', 150),
  ('Alice', 400), ('Bob', 600),
])
conn.commit()

# TODO: Return (rep, total_sales) for reps whose total exceeds 1200
# sorted by total_sales DESC

def top_reps(conn):
  pass

for row in top_reps(conn):
  print(row)
Expected Output
('Bob', 1800.0)
('Alice', 1600.0)
Hints

Hint 1: Use HAVING SUM(amount) > 1200 after GROUP BY.

Hint 2: HAVING filters aggregated groups; WHERE filters individual rows.

Hint 3: Order the results by the SUM descending.


#5LEFT JOIN and NULL DetectionMedium
LEFT JOINNULLIS NULL

Use a LEFT JOIN to find all customers who have placed zero orders. Return a sorted list of their names.

Solution:

def customers_without_orders(conn):
cur = conn.execute("""
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL
ORDER BY c.name
""")
return [row[0] for row in cur.fetchall()]
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL);
INSERT INTO customers VALUES (1,'Alice'),(2,'Bob'),(3,'Carol'),(4,'Dave');
INSERT INTO orders VALUES (1,1,250.0),(2,1,75.0),(3,3,400.0);
""")

# TODO: Return names of customers who have NO orders

def customers_without_orders(conn):
  pass

print(customers_without_orders(conn))
Expected Output
['Bob', 'Dave']
Hints

Hint 1: LEFT JOIN keeps all rows from the left table even with no match.

Hint 2: Where there is no match, the joined columns are NULL.

Hint 3: Filter with WHERE o.id IS NULL to find unmatched customers.


#6Correlated SubqueryMedium
subquerycorrelatedWHERE EXISTS

Write a correlated subquery that finds products priced above their category's average price, sorted by price descending.

Solution:

def above_category_avg(conn):
cur = conn.execute("""
SELECT p1.name, p1.price, p1.category
FROM products p1
WHERE p1.price > (
SELECT AVG(p2.price)
FROM products p2
WHERE p2.category = p1.category
)
ORDER BY p1.price DESC
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, category TEXT);
INSERT INTO products VALUES
  (1,'Laptop',999,'Electronics'),
  (2,'Phone',699,'Electronics'),
  (3,'Tablet',499,'Electronics'),
  (4,'Desk',350,'Furniture'),
  (5,'Chair',275,'Furniture'),
  (6,'Monitor',450,'Electronics');
""")

# TODO: Return products that are priced above the average price
# of their own category. Return (name, price, category).

def above_category_avg(conn):
  pass

for row in above_category_avg(conn):
  print(row)
Expected Output
('Laptop', 999.0, 'Electronics')
('Monitor', 450.0, 'Electronics')
('Desk', 350.0, 'Furniture')
Hints

Hint 1: A correlated subquery references the outer query's row.

Hint 2: SELECT AVG(price) FROM products p2 WHERE p2.category = p1.category gives the per-category average.

Hint 3: Filter WHERE p1.price > (subquery).


#7Window Function — ROW_NUMBERMedium
window functionROW_NUMBERPARTITION BY

Use a window function with ROW_NUMBER() PARTITION BY to find the highest-scoring student in each subject.

Solution:

def top_per_subject(conn):
cur = conn.execute("""
SELECT subject, student, score
FROM (
SELECT subject, student, score,
ROW_NUMBER() OVER (PARTITION BY subject ORDER BY score DESC) AS rn
FROM scores
)
WHERE rn = 1
ORDER BY subject
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE scores (
  student TEXT,
  subject TEXT,
  score INTEGER
);
INSERT INTO scores VALUES
  ('Alice','Math',92),('Alice','Science',85),('Alice','English',78),
  ('Bob','Math',88),('Bob','Science',91),('Bob','English',72),
  ('Carol','Math',95),('Carol','Science',80),('Carol','English',90);
""")

# TODO: For each subject, return the top-scoring student.
# Return (subject, student, score).

def top_per_subject(conn):
  pass

for row in top_per_subject(conn):
  print(row)
Expected Output
('English', 'Carol', 90)
('Math', 'Carol', 95)
('Science', 'Bob', 91)
Hints

Hint 1: Use ROW_NUMBER() OVER (PARTITION BY subject ORDER BY score DESC) to rank within each subject.

Hint 2: Wrap in a subquery or CTE, then filter WHERE rn = 1.

Hint 3: SQLite supports window functions since version 3.25.


#8CTE — Common Table ExpressionMedium
CTEWITHrecursive query

Use a CTE to calculate net balance per account (credits minus debits), returning results sorted by balance descending.

Solution:

def account_balances(conn):
cur = conn.execute("""
WITH balances AS (
SELECT
account,
SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END) AS balance
FROM transactions
GROUP BY account
)
SELECT account, balance
FROM balances
ORDER BY balance DESC
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE transactions (
  id INTEGER PRIMARY KEY,
  account TEXT,
  amount REAL,
  type TEXT  -- 'credit' or 'debit'
);
INSERT INTO transactions VALUES
  (1,'ACC001',1000,'credit'),
  (2,'ACC001',200,'debit'),
  (3,'ACC001',500,'credit'),
  (4,'ACC002',3000,'credit'),
  (5,'ACC002',1500,'debit'),
  (6,'ACC003',750,'credit');
""")

# TODO: Use a CTE to calculate the balance for each account
# (sum of credits - sum of debits).
# Return (account, balance) sorted by balance DESC.

def account_balances(conn):
  pass

for row in account_balances(conn):
  print(row)
Expected Output
('ACC002', 1500.0)
('ACC001', 1300.0)
('ACC003', 750.0)
Hints

Hint 1: WITH credits AS (SELECT account, SUM(amount) ... WHERE type='credit') creates a CTE.

Hint 2: Create two CTEs — one for credits, one for debits — then JOIN them.

Hint 3: Alternatively, use CASE WHEN type='credit' THEN amount ELSE -amount END in a single SUM.


#9Running Total with Window FrameHard
window functionSUM OVERROWS BETWEEN

Compute a running total and 3-day moving average of revenue using SQL window frame clauses.

Solution:

def revenue_analytics(conn):
cur = conn.execute("""
SELECT
day,
revenue,
SUM(revenue) OVER (
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3day
FROM daily_revenue
ORDER BY day
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE daily_revenue (
  day INTEGER,
  revenue REAL
);
INSERT INTO daily_revenue VALUES
  (1, 100),(2, 150),(3, 80),(4, 200),(5, 120),
  (6, 175),(7, 90),(8, 250),(9, 130),(10, 160);
""")

# TODO: Return (day, revenue, running_total, moving_avg_3day)
# running_total: cumulative sum up to and including that day
# moving_avg_3day: average of current day + previous 2 days

def revenue_analytics(conn):
  pass

for row in revenue_analytics(conn):
  print(row)
Expected Output
(1, 100.0, 100.0, 100.0)
(2, 150.0, 250.0, 125.0)
(3, 80.0, 330.0, 110.0)
(4, 200.0, 530.0, 143.33333333333334)
(5, 120.0, 650.0, 133.33333333333334)
(6, 175.0, 825.0, 165.0)
(7, 90.0, 915.0, 128.33333333333334)
(8, 250.0, 1165.0, 171.66666666666666)
(9, 130.0, 1295.0, 156.66666666666666)
(10, 160.0, 1455.0, 180.0)
Hints

Hint 1: SUM(revenue) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) gives a running total.

Hint 2: AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) gives the 3-day moving average.

Hint 3: Both window functions can appear in the same SELECT.


#10Recursive CTE — Organizational HierarchyHard
recursive CTEWITH RECURSIVEhierarchy

Write a recursive CTE to traverse an organizational hierarchy and assign a depth level to each person.

Solution:

def org_depth(conn):
cur = conn.execute("""
WITH RECURSIVE hierarchy AS (
SELECT id, name, 0 AS depth
FROM org
WHERE manager_id IS NULL
UNION ALL
SELECT o.id, o.name, h.depth + 1
FROM org o
JOIN hierarchy h ON o.manager_id = h.id
)
SELECT name, depth
FROM hierarchy
ORDER BY depth, name
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE org (
  id INTEGER PRIMARY KEY,
  name TEXT,
  manager_id INTEGER
);
INSERT INTO org VALUES
  (1,'CEO',NULL),
  (2,'VP Engineering',1),
  (3,'VP Marketing',1),
  (4,'Eng Manager',2),
  (5,'Alice',4),
  (6,'Bob',4),
  (7,'Carol',2),
  (8,'Marketing Lead',3),
  (9,'Dave',8);
""")

# TODO: Return each person's name and their depth in the hierarchy
# (CEO = 0, VPs = 1, Managers = 2, etc.)
# Return (name, depth) sorted by depth, then name.

def org_depth(conn):
  pass

for row in org_depth(conn):
  print(row)
Expected Output
('CEO', 0)
('VP Engineering', 1)
('VP Marketing', 1)
('Carol', 2)
('Eng Manager', 2)
('Marketing Lead', 2)
('Alice', 3)
('Bob', 3)
('Dave', 3)
Hints

Hint 1: WITH RECURSIVE cte AS (base_case UNION ALL recursive_case) builds the hierarchy.

Hint 2: Base case: SELECT id, name, 0 AS depth FROM org WHERE manager_id IS NULL.

Hint 3: Recursive case: JOIN org to cte on org.manager_id = cte.id, increment depth.


#11Multi-Table Analytics QueryHard
multi-joinwindow functionCTEanalytics

Build a complex analytics query combining JOINs, CTEs, and window functions to produce a category performance report.

Solution:

def category_report(conn):
cur = conn.execute("""
WITH product_revenue AS (
SELECT
p.id,
p.name AS product_name,
p.category,
SUM(p.price * oi.qty) AS rev,
COUNT(DISTINCT oi.order_id) AS orders
FROM products p
JOIN order_items oi ON oi.product_id = p.id
GROUP BY p.id, p.name, p.category
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY rev DESC) AS rn
FROM product_revenue
),
cat_totals AS (
SELECT
category,
SUM(rev) AS total_revenue,
SUM(orders) AS order_count
FROM product_revenue
GROUP BY category
)
SELECT
ct.category,
ct.total_revenue,
ct.order_count,
r.product_name AS top_product
FROM cat_totals ct
JOIN ranked r ON r.category = ct.category AND r.rn = 1
ORDER BY ct.category
""")
return cur.fetchall()
import sqlite3

conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, category TEXT, price REAL);
CREATE TABLE order_items (order_id INTEGER, product_id INTEGER, qty INTEGER);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, order_date TEXT);

INSERT INTO products VALUES
  (1,'Widget','Hardware',25.0),(2,'Gadget','Electronics',150.0),
  (3,'Doohickey','Hardware',40.0),(4,'Thingamajig','Electronics',200.0),
  (5,'Whatsit','Hardware',15.0);
INSERT INTO orders VALUES
  (1,'Alice','2024-01-10'),(2,'Bob','2024-01-15'),
  (3,'Alice','2024-02-01'),(4,'Carol','2024-02-10');
INSERT INTO order_items VALUES
  (1,1,3),(1,2,1),(2,3,2),(2,4,1),
  (3,1,1),(3,5,4),(4,2,2),(4,3,1);
""")

# TODO: For each category, find:
# - total revenue (price * qty)
# - number of distinct orders
# - best-selling product (by revenue) within the category
# Return (category, total_revenue, order_count, top_product)

def category_report(conn):
  pass

for row in category_report(conn):
  print(row)
Expected Output
('Electronics', 850.0, 3, 'Thingamajig')
('Hardware', 315.0, 4, 'Doohickey')
Hints

Hint 1: First compute revenue per product: JOIN order_items to products.

Hint 2: Use ROW_NUMBER() OVER (PARTITION BY category ORDER BY product_revenue DESC) to rank within category.

Hint 3: Join back to get per-category totals alongside the per-product ranking.

© 2026 EngineersOfAI. All rights reserved.