Advanced SQL: GROUP BY, NULL, subqueries, DML and views
Aggregate functions, GROUP BY/HAVING, the three-valued logic of NULL, IN/EXISTS/correlated subqueries, set operations, INSERT/UPDATE/DELETE and views. The hardest SQL questions on the midterm come from here.
In week six you selected and joined individual rows. This week we summarize rows (how many, what is the average), put queries inside queries, and change the data. The hard questions in the SQL part of the midterm almost always rest on one of these three things: the GROUP BY/HAVING distinction, the strange behavior of NULL, and the “those who did every X” (division) question.
Golden rules:
- WHERE filters rows, HAVING filters groups. An aggregate function cannot go inside
WHERE. - A comparison with NULL gives UNKNOWN, not TRUE or FALSE.
WHEREkeeps only the rows that are TRUE. NOT INand NULL together are poison. If there is a NULL in the subquery,NOT INreturns no rows.- A question with “every/all” is a division question. Write it with a double
NOT EXISTS. COUNT(*)counts rows,COUNT(column)counts non-NULL values.
The sample database is the same as in week 6 (department, instructor, student, course, teaches, takes).
Aggregate functions
There are five basic aggregates: COUNT, SUM, AVG, MIN, MAX. They reduce the many values in a column to one value.
SELECT COUNT(*), COUNT(grade), COUNT(DISTINCT stu_id) FROM takes;
| COUNT(*) | COUNT(grade) | COUNT(DISTINCT stu_id) |
|---|---|---|
| 14 | 13 | 6 |
There are 14 records. One has a NULL grade (Cem, CS101), so COUNT(grade) is 13. The number of distinct students who take courses is 6 (Firat is missing).
SELECT SUM(salary), AVG(salary), MIN(salary), MAX(salary) FROM instructor;
| SUM(salary) | AVG(salary) | MIN(salary) | MAX(salary) |
|---|---|---|---|
| 462000 | 77000 | 60000 | 95000 |
In SQLite, AVG always returns REAL (typeof = real), and SUM stays INTEGER on an integer column. That is why AVG(credits) gives 3.4285714285714284. But 7/2 is still 3: only AVG switches to decimals on its own.
On an empty set COUNT returns 0 and the others return NULL: with WHERE salary > 100000, COUNT(*), SUM(salary), AVG(salary) gives 0, NULL, NULL.
GROUP BY and HAVING
GROUP BY splits rows into groups by a common value, and the aggregate is applied to each group separately.
SELECT dept_name, COUNT(*) AS n, AVG(salary) AS avg_sal
FROM instructor GROUP BY dept_name;
| dept_name | n | avg_sal |
|---|---|---|
| BIO | 1 | 60000 |
| CS | 2 | 87500 |
| EE | 1 | 85000 |
| MATH | 2 | 71000 |
HAVING puts a condition on the groups:
SELECT dept_name, AVG(salary) AS avg_sal
FROM instructor GROUP BY dept_name HAVING AVG(salary) > 80000;
| dept_name | avg_sal |
|---|---|
| CS | 87500 |
| EE | 85000 |
WHERE and HAVING together
“Departments that have at least 2 students among those with more than 20 credits”:
SELECT dept_name, COUNT(*) AS n
FROM student
WHERE tot_cred > 20
GROUP BY dept_name
HAVING COUNT(*) >= 2;
| dept_name | n |
|---|---|
| CS | 2 |
Step by step: WHERE first drops Ece (15), Firat (0) and Gizem (10). The rest are grouped: CS 2 (Ada, Bora), EE 1 (Cem), MATH 1 (Defne). HAVING leaves only CS.
FROM student 7 rows
WHERE tot_cred > 20 4 rows: Ada, Bora, Cem, Defne
GROUP BY dept_name CS{Ada,Bora} EE{Cem} MATH{Defne}
HAVING COUNT(*) >= 2 CS
SELECT CS, 2
PITFALL · an ungrouped column. In standard SQL, every column in the
SELECTlist that is not an aggregate must be inGROUP BY.SELECT dept_name, name, MAX(salary) FROM instructor GROUP BY dept_namegives an error in PostgreSQL. SQLite gives no error and picks a row from the group forname(when used with MAX, the name of the highest paid: Aksoy, Kaya, Celik, Yildiz). In the exam, count this query as wrong.
Counting zeros
The number of students in each course, including those with no students:
SELECT c.course_id, COUNT(t.stu_id) AS enrolled
FROM course c LEFT JOIN takes t ON c.course_id = t.course_id
GROUP BY c.course_id;
| course_id | enrolled |
|---|---|
| BI100 | 0 |
| CS101 | 4 |
| CS201 | 2 |
| CS305 | 2 |
| EE210 | 1 |
| MA101 | 3 |
| MA202 | 2 |
If you had written COUNT(*), BI100 would give 1, because LEFT JOIN brings it with the row (BI100, NULL, NULL).
NULL and three-valued logic
NULL means “unknown” or “absent”. In arithmetic it is contagious: 5 + NULL = NULL. In comparison the result is UNKNOWN: even NULL = NULL is UNKNOWN.
| AND | TRUE | UNKNOWN | FALSE |
|---|---|---|---|
| TRUE | TRUE | UNKNOWN | FALSE |
| UNKNOWN | UNKNOWN | UNKNOWN | FALSE |
| FALSE | FALSE | FALSE | FALSE |
For OR: TRUE OR UNKNOWN = TRUE, FALSE OR UNKNOWN = UNKNOWN. NOT UNKNOWN = UNKNOWN. In SQLite, SELECT NULL = NULL, NULL AND 0, NULL OR 1, NULL AND 1, NOT NULL gives NULL, 0, 1, NULL, NULL (0 = FALSE, 1 = TRUE).
Consequences:
WHERE dept_name = NULLreturns no rows. The right way isWHERE dept_name IS NULL→ Gizem.WHERE dept_name <> 'CS'returns 3 rows (Cem, Defne, Firat). Gizem does not come:NULL <> 'CS'is UNKNOWN.WHERE grade = 'A' OR grade <> 'A'gives 13 rows, not 14. A condition that looks like “everything” misses NULL.GROUP BY, on the other hand, treats NULLs as one group:GROUP BY dept_nameon students gives NULL 1, CS 3, EE 2, MATH 1.
Subqueries
IN
Students who took a course taught by I1 (Aksoy):
SELECT name FROM student
WHERE stu_id IN (SELECT t.stu_id FROM takes t JOIN teaches te ON t.course_id = te.course_id
WHERE te.inst_id = 'I1');
Result: Ada, Bora, Cem, Defne, Ece. Aksoy teaches CS101 and CS305; CS101 was taken by S1, S2, S3, S5, and CS305 by S1, S4. IN produces no duplicates, so Ada appears once.
The NOT IN trap and NOT EXISTS
Let us look for departments with no students:
SELECT dept_name FROM department
WHERE dept_name NOT IN (SELECT dept_name FROM student);
The result is empty. The subquery also returns Gizem’s NULL. 'BIO' NOT IN (CS, EE, MATH, NULL) actually means 'BIO' <> 'CS' AND ... AND 'BIO' <> NULL, and the last term is UNKNOWN. The right way:
SELECT d.dept_name FROM department d
WHERE NOT EXISTS (SELECT * FROM student s WHERE s.dept_name = d.dept_name);
| dept_name |
|---|
| BIO |
PITFALL · NOT IN + NULL. If the subquery returns even a single NULL,
NOT INis never TRUE. Either useNOT EXISTSor addWHERE ... IS NOT NULLto the subquery.INhas no such problem.
EXISTS and the correlated subquery
EXISTS (subquery) is TRUE if the subquery returns at least one row. If the subquery refers to the outer row, it is called correlated; it is re-evaluated for each outer row.
SELECT c.course_id FROM course c
WHERE EXISTS (SELECT * FROM takes t WHERE t.course_id = c.course_id AND t.grade = 'A');
Result: CS101, CS305, EE210, MA101, MA202 (courses in which at least one A was given).
Those who earn more than their own department’s average:
SELECT i.name, i.salary FROM instructor i
WHERE i.salary > (SELECT AVG(i2.salary) FROM instructor i2 WHERE i2.dept_name = i.dept_name);
| name | salary |
|---|---|
| Aksoy | 95000 |
| Celik | 72000 |
Scalar subquery
A subquery that returns one row and one column can be used like a value, even in the SELECT list:
SELECT d.dept_name,
(SELECT COUNT(*) FROM instructor i WHERE i.dept_name = d.dept_name) AS n_inst
FROM department d;
Result: BIO 1, CS 2, EE 1, MATH 2. A subquery can also be in FROM: SELECT ... FROM (SELECT dept_name, AVG(salary) AS avg_sal FROM instructor GROUP BY dept_name) WHERE avg_sal > 80000 gives the same result as the HAVING example.
ALL and SOME
In standard SQL, x > ALL (set) means “greater than every element of the set”, and x > SOME (set) (or ANY) means “greater than at least one”.
| Standard SQL | Equivalent (works in SQLite) | Result for the MATH salaries |
|---|---|---|
salary > ALL (MATH salaries) | salary > (SELECT MAX(...)) | Aksoy, Demir, Kaya |
salary > SOME (MATH salaries) | salary > (SELECT MIN(...)) | Aksoy, Demir, Kaya, Celik |
= SOME | IN | |
<> ALL | NOT IN |
PITFALL · no ALL/SOME in SQLite.
> ALL (...)gives asyntax errorin SQLite. In the exam, know the standard form, and in SQLite write it with MAX/MIN. Celik at 72000 is greater than the 70000 in MATH but not greater than 72000 (himself): he is in SOME, not in ALL.
Division: “those who took all of them”
There is no direct SQL counterpart of the operation in relational algebra. Translate the question “students who took all CS courses” into “students for whom there is no CS course not taken”:
SELECT s.name FROM student s
WHERE NOT EXISTS (
SELECT c.course_id FROM course c WHERE c.dept_name = 'CS'
EXCEPT
SELECT t.course_id FROM takes t WHERE t.stu_id = s.stu_id);
| name |
|---|
| Ada |
For each student, “the CS courses minus the ones they took” is computed. If the set is empty, the student took all of them. The same query can also be written with a double NOT EXISTS (the questions section has the MATH example).
Set operations
| Operation | Meaning | Duplicates |
|---|---|---|
UNION | removes | |
INTERSECT | removes | |
EXCEPT (Oracle: MINUS) | removes | |
UNION ALL | multiset union | keeps |
SELECT course_id FROM takes WHERE stu_id = 'S1'
INTERSECT
SELECT course_id FROM takes WHERE stu_id = 'S2';
Result: CS101, CS201. If you turn it into EXCEPT (S1 minus S2), you get CS305, MA101. SELECT course_id FROM course EXCEPT SELECT course_id FROM takes gives the course nobody took: BI100.
SELECT dept_name FROM instructor UNION SELECT dept_name FROM student returns 5 rows (NULL, BIO, CS, EE, MATH); with UNION ALL, 6 + 7 = 13. The two sides must have compatible numbers of columns and types. Standard SQL also has INTERSECT ALL and EXCEPT ALL; SQLite does not.
Changing data: INSERT, UPDATE, DELETE
Each example was run on a fresh copy of the database.
INSERT INTO course (course_id, title, dept_name, credits)
VALUES ('CS410', 'Machine Learning', 'CS', 3);
-- enroll the CS students with 30+ credits in the new course
INSERT INTO takes (stu_id, course_id, grade)
SELECT stu_id, 'CS410', NULL FROM student WHERE dept_name = 'CS' AND tot_cred >= 30;
The second statement inserts two rows, for S1 and S2.
UPDATE instructor SET salary = salary * 1.05 WHERE salary < 75000;
Sahin becomes 73500, Celik 75600, Yildiz 63000, and the others do not change. If there are two different rules, use CASE in a single statement; if you write two separate UPDATEs, a row raised by the first may fall under the condition of the second:
UPDATE instructor
SET salary = CASE WHEN salary < 80000 THEN salary + 5000 ELSE salary + 2000 END;
Result: Aksoy 97000, Demir 82000, Kaya 87000, Sahin 75000, Celik 77000, Yildiz 65000.
DELETE FROM takes WHERE grade = 'F'; -- 14 -> 13 rows
DELETE FROM takes
WHERE stu_id IN (SELECT stu_id FROM student WHERE tot_cred < 20); -- 13 -> 12
DELETE FROM instructor WHERE salary < (SELECT AVG(salary) FROM instructor) is interesting: does the average change during the deletion? No. SQL computes the subquery once before deleting (77000) and then deletes. On a copy with FK checking off, Sahin, Celik and Yildiz are deleted. With FK on, the statement is rejected, because Sahin and Celik teach courses in the teaches table.
Views
A view is a saved query that has a name. It copies no data; its definition runs again every time it is used.
CREATE VIEW cs_roster AS
SELECT s.name, t.course_id, t.grade
FROM student s JOIN takes t ON s.stu_id = t.stu_id
WHERE s.dept_name = 'CS';
SELECT course_id, COUNT(*) AS n FROM cs_roster GROUP BY course_id;
| course_id | n |
|---|---|
| CS101 | 3 |
| CS201 | 2 |
| CS305 | 1 |
| MA101 | 1 |
When a record for a new CS student is added to the takes table, the view grows from 7 to 8 rows on its own. Views are used for security (showing a user only some columns) and for simplifying complex queries. INSERT through a view is not possible in every system; SQLite says cannot modify cs_roster because it is a view.
Common mistakes
| Wrong | Right |
|---|---|
WHERE AVG(salary) > 75000 | An aggregate condition goes in HAVING |
SELECT dept_name, name, MAX(salary) ... GROUP BY dept_name | name is not grouped; an error in standard SQL |
Inner join + COUNT(*) for groups with zeros | LEFT JOIN + COUNT(right.column) |
WHERE x = NULL | WHERE x IS NULL |
NOT IN (subquery) when the subquery contains NULL | NOT EXISTS or an IS NOT NULL filter |
Counting rows with COUNT(grade) | NULL grades are not counted; use COUNT(*) for rows |
IN for “took all of them” | IN gives “at least one”; for division use a double NOT EXISTS |
> SOME instead of > ALL | ALL = greater than the largest, SOME = greater than the smallest |
Thinking UNION keeps duplicates | The one that keeps them is UNION ALL |
Doing two rules with two separate UPDATEs | They can interact; use a single UPDATE + CASE |
| Thinking a view is a copy of a table | It is a saved query; the data is recomputed every time |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
What does the query below return?
SELECT COUNT(*), COUNT(grade)
FROM takes
WHERE course_id = 'CS101';Show answer
Those who took CS101: S1 (A), S2 (B), S3 (NULL), S5 (F). Four rows, so COUNT(*) = 4.COUNT(grade) counts the non-NULL values. Cem's grade is NULL, so it is not counted: 3.
Write an SQL query that lists each department name with the average salary of its instructors, but only for departments whose average salary is above 75000.
Show answer
Because the condition is per group, it is HAVING, not WHERE.
Group averages: BIO 60000, CS (95000 + 80000)/2 = 87500, EE 85000, MATH (70000 + 72000)/2 = 71000.
Above 75000: CS 87500, EE 85000.
Write an SQL query that lists every course_id with the number of students enrolled in it. Courses with no students must appear with 0.
Show answer
There are two traps. If you use an inner join, BI100 never appears, so use LEFT JOIN.
If you write COUNT(*), BI100 gives 1, because LEFT JOIN brings it as a row full of NULLs. COUNT(t.stu_id) does not count NULL and gives 0.
Result: BI100 0, CS101 4, CS201 2, CS305 2, EE210 1, MA101 3, MA202 2.
The query below is meant to list departments that have no students. What does it return on our database, why, and how do you fix it?
SELECT dept_name FROM department
WHERE dept_name NOT IN (SELECT dept_name FROM student);Show answer
The subquery returns: CS, CS, EE, MATH, CS, EE, NULL.
For BIO, 'BIO' NOT IN (..., NULL) means 'BIO' <> 'CS' AND ... AND 'BIO' <> NULL. The last term is unknown. TRUE AND unknown = unknown, so the row is not selected. No department gets TRUE: the result is empty.
Fix 1: NOT EXISTS (SELECT * FROM student s WHERE s.dept_name = d.dept_name).
Fix 2: add WHERE dept_name IS NOT NULL to the subquery. Both return BIO.
Write an SQL query using NOT EXISTS that lists the names of students who have not taken any course.
Show answer
A correlated subquery: for each outer row s, the inner query looks for that student's records. If there are no rows, NOT EXISTS is TRUE.
The only student with no records: Firat (S6).
Write an SQL query that lists the names of students who have taken every course offered by the MATH department.
Show answer
An "every" question is a division question. In SQL it is written with a double NOT EXISTS: "students for whom there is no MATH course they did not take".
The MATH courses: MA101, MA202. Those who took MA101: S1, S3, S4; those who took MA202: S4, S7. Only S4 took both: Defne.
The same question can also be written as NOT EXISTS (MATH courses EXCEPT the student's courses). In relational algebra, also gave S4.
In standard SQL, the query below lists instructors earning more than every MATH instructor. SQLite rejects it. Rewrite it so it runs in SQLite and give the result.
SELECT name FROM instructor
WHERE salary > ALL (SELECT salary FROM instructor
WHERE dept_name = 'MATH');Show answer
> ALL (set) means being greater than the largest value in the set. So it is the same as > (SELECT MAX(...)). (> SOME becomes > MIN.)
The MATH salaries are 70000 and 72000, and the largest is 72000. Those above it: Aksoy 95000, Demir 80000, Kaya 85000.
SQLite does not support ALL/SOME/ANY comparisons (syntax error). PostgreSQL and Oracle do.
Note: if MATH had no instructors at all, > ALL would give TRUE for every row, while > MAX (a comparison with NULL) would give no rows.
teaches has 6 rows and takes has 14 rows. How many rows does each query return? (a) SELECT course_id FROM teaches UNION ALL SELECT course_id FROM takes; (b) the same with UNION.
Show answer
UNION ALL keeps duplicates: 6 + 14 = 20.UNION removes duplicates. The distinct course_id values in the two tables are CS101, CS201, CS305, EE210, MA101, MA202, that is, 6. BI100 is in neither.
Write an SQL query that lists the name and salary of instructors who earn more than the average salary of their own department.
Show answer
Because it says "their own department", the subquery depends on the outer row: a correlated subquery. The inner i2.dept_name = i.dept_name makes that link.
Department averages: CS 87500, EE 85000, MATH 71000, BIO 60000.
Those above the average: Aksoy (95000 > 87500), Celik (72000 > 71000). Kaya and Yildiz are the only instructors of their departments, so they are equal to the average, not greater.
After running the statement below, what is Ada's (S1) tot_cred? Which other students change?
UPDATE student
SET tot_cred = tot_cred + 5
WHERE stu_id IN (SELECT stu_id FROM takes WHERE grade = 'A');Show answer
The subquery finds those with an A: S1 (two of them), S3, S4. IN asks for membership, so S1 having two A grades does not update it twice.
Ada 60 + 5 = 65, Cem 45 + 5 = 50, Defne 90 + 5 = 95. The others do not change.
Find the bug and fix it.
SELECT dept_name
FROM instructor
WHERE AVG(salary) > 75000
GROUP BY dept_name;Show answer
WHERE is applied to individual rows before grouping. At that point there are no groups yet, so AVG cannot be computed. SQLite message: misuse of aggregate function AVG(). (Also, WHERE cannot be written after GROUP BY.)
The group condition is written with HAVING. The corrected query returns CS and EE.
Give the result table of SELECT grade, COUNT(*) FROM takes GROUP BY grade ORDER BY grade;
Show answer
GROUP BY collects NULL values into a single group (unlike the = logic in WHERE). Since COUNT(*) is used here, the NULL group counts 1 (Cem's CS101). With COUNT(grade) that row would be 0.
A: S1-CS101, S1-CS305, S3-EE210, S4-MA101, S4-MA202 = 5. B: S1-CS201, S1-MA101, S2-CS101, S4-CS305, S7-MA202 = 5. C: S2-CS201, S3-MA101 = 2. F: S5-CS101 = 1.
In ascending order SQLite puts NULL first. The total 1 + 5 + 5 + 2 + 1 = 14 = the number of rows in takes.
Verification: Every SQL statement on the page and every question answer was run with node:sqlite (SQLite 3.53.4) on the sample university database; each DML example on a fresh copy of the database. That ALL/SOME give an error in SQLite and that the MAX/MIN equivalents give the same result was verified with both SQL and JS; the division query was compared with the relational algebra division in JS (verify/SEN2104/07.mjs).