CMP-325 · Lesson 4 of 12 · 31 min

SQL II: queries, joins and aggregation

Most work with a database is asking questions of it. This lesson covers the core of SQL querying: the SELECT-FROM-WHERE block, sorting and limiting results, conditions and the three-valued logic that NULL brings, the different kinds of join, and aggregation with GROUP BY and HAVING. All examples run against the college database defined in lesson 3. By the end you should be able to translate ordinary questions, such as 'which instructors teach more than one section, and what is their average class grade?', into correct SQL, and to predict what a query returns before you run it.

The basic query block

A query has the shape SELECT columns FROM tables WHERE condition. FROM names the tables; WHERE keeps the rows for which the condition is true; SELECT chooses the output columns, which may be expressions, and can rename them with AS. In relational algebra terms, FROM is a Cartesian product, WHERE is a selection and SELECT is a projection, except that SQL keeps duplicate rows unless you write SELECT DISTINCT. ORDER BY sorts the result, ascending by default or DESC for descending, and LIMIT keeps only the first rows. Without ORDER BY, the order of rows is undefined and may change between runs or versions, even if it looks stable.

Selecting, computing, sorting, removing duplicates and filtering with LIKE and IN
SELECT name, credits, credits / 3 AS approx_courses
FROM student
WHERE credits >= 45
ORDER BY credits DESC;

SELECT DISTINCT dept_id FROM course ORDER BY dept_id;

SELECT course_id, title FROM course
WHERE title LIKE '%Algorithms%' OR course_id IN ('MATH-125', 'STAT-151');
Output
name   credits  approx_courses
-----  -------  --------------
Grace  90       30
Chloe  87       29
Elena  66       22
Ava    54       18
Farid  45       15

dept_id
-------
BIO
CS
MATH
PHYS

course_id  title
---------  --------------------------
CMPUT-204  Algorithms I
CMPUT-304  Algorithms II
MATH-125   Linear Algebra I
STAT-151   Introduction to Statistics

Integer division explains the approx_courses column: in SQLite, PostgreSQL and SQL Server, dividing two integers truncates, so 54 / 3 is 18 and 45 / 3 is 15, while MySQL returns a decimal. Write 54 / 3.0 or CAST(credits AS REAL) to get a fractional result portably. Conditions can use comparison operators, AND, OR and NOT, BETWEEN low AND high (inclusive), IN (list), and LIKE with the wildcards % (any sequence of characters) and _ (exactly one character). Whether LIKE is case-sensitive varies by system: SQLite's is case-insensitive for ASCII letters, PostgreSQL's is case-sensitive and offers ILIKE.

How SQL evaluates a query

The clauses of a query are written in one order and evaluated, logically, in another. Understanding the logical order explains many puzzling errors, such as why a column alias defined in SELECT cannot be used in WHERE in standard SQL.

The logical evaluation order of a query
Logical stepClauseWhat it does
1FROM and JOINBuilds the combined rows from the tables
2WHEREKeeps rows whose condition is true
3GROUP BYForms groups of rows with equal grouping values
4HAVINGKeeps groups whose condition is true
5SELECTComputes output columns and aliases; applies DISTINCT
6ORDER BYSorts the result (may use aliases)
7LIMIT / OFFSETKeeps a slice of the sorted rows

The DBMS may physically compute things in a different order, using indexes and pushing work around as lesson 9 shows, but the answer must be as if it followed these steps. Because WHERE runs before SELECT, a condition on an alias defined in SELECT is not allowed in standard SQL (SQLite and MySQL accept it as an extension); repeat the expression or use a subquery. Because WHERE runs before GROUP BY, conditions on aggregates such as COUNT(*) must go in HAVING, not WHERE.

NULL and three-valued logic

A comparison involving NULL is neither true nor false; its result is unknown. So credits > 50 is unknown when credits is NULL, and so is NULL = NULL. SQL therefore uses three-valued logic with true, false and unknown. WHERE keeps only rows whose condition is true, so rows where it is unknown are dropped, just like false ones. To test for NULL, use IS NULL and IS NOT NULL, never = NULL, which is always unknown.

Three-valued logic: the rows involving unknown
pqp AND qp OR qNOT p
trueunknownunknowntruefalse
falseunknownfalseunknowntrue
unknownunknownunknownunknownunknown
NULL traps: = NULL, NOT IN with a NULL, and the COALESCE function
-- Which students have no department?
SELECT name FROM student WHERE dept_id = NULL;      -- always unknown: returns nothing
SELECT name FROM student WHERE dept_id IS NULL;     -- correct

-- NOT IN with a NULL in the list returns nothing at all
SELECT name FROM instructor WHERE dept_id NOT IN ('CS', NULL);

-- COALESCE substitutes a value for NULL in output
SELECT name, COALESCE(dept_id, '(none)') AS dept FROM student WHERE student_id IN (101, 108);
Output
name
----
(no rows)

name
----
Hiro

name
----
(no rows)

name  dept
----  ------
Ava   CS
Hiro  (none)

The NOT IN result surprises almost everyone. The condition dept_id NOT IN ('CS', NULL) means dept_id <> 'CS' AND dept_id <> NULL. The second part is unknown for every row, so the whole condition is at best unknown, and no row is ever kept. The same trap appears when NOT IN is used with a subquery that can return a NULL; lesson 5 shows the safer NOT EXISTS. Aggregate functions also treat NULL specially: COUNT(column), SUM, AVG, MIN and MAX ignore NULLs, while COUNT(*) counts rows. An average of grades therefore averages only the grades that exist, which is usually, but not always, what you want.

Joins

Joins combine rows from several tables. The modern syntax writes the join in FROM with JOIN ... ON condition. An inner join keeps pairs of rows that satisfy the condition. The older style lists tables separated by commas and puts the join condition in WHERE; it means the same for inner joins but makes it easy to forget a condition, which silently produces a Cartesian product. USING (column) is shorthand for equality on same-named columns and outputs the column once; NATURAL JOIN joins on all same-named columns, which is fragile, because adding a column to one table can silently change the join.

Four pairs of overlapping circles labeled students and departments. Inner join shades only the overlap; left outer join shades the whole left circle; right outer join the whole right circle; full outer join both circles.
Which rows each kind of join keeps
An inner join loses Hiro (no department); a left join keeps History (no students)
-- Inner join: each student with the name of their department
SELECT s.name AS student, d.name AS department
FROM student AS s
JOIN department AS d ON s.dept_id = d.dept_id
ORDER BY d.name, s.name;

-- Left outer join: every department, with its number of students (0 if none)
SELECT d.name, COUNT(s.student_id) AS students
FROM department AS d
LEFT JOIN student AS s ON s.dept_id = d.dept_id
GROUP BY d.dept_id
ORDER BY students DESC, d.name;
Output
student  department
-------  -----------------
Elena    Biology
Jonah    Biology
Ava      Computing Science
Ben      Computing Science
Dev      Computing Science
Grace    Computing Science
Chloe    Mathematics
Isla     Mathematics
Farid    Physics

name               students
-----------------  --------
Computing Science  4
Biology            2
Mathematics        2
Physics            1
History            0

The table aliases s and d shorten the query and are required when the same table appears twice. A left outer join keeps every row of the left table; where no right row matches, the right columns are NULL. A right outer join does the reverse, and a full outer join keeps unmatched rows from both sides. SQLite supports RIGHT and FULL joins since version 3.39; MySQL and MariaDB still do not support FULL OUTER JOIN, which can be written as a union of a left and a right join. A useful habit is to count with COUNT(column from the right table) after a left join, so that unmatched rows count as zero rather than one.

A self-join with aliases, and a three-table join keeping a section with no instructor assigned
-- Self-join: each instructor with their mentor's name
SELECT i.name AS instructor, m.name AS mentor
FROM instructor AS i
LEFT JOIN instructor AS m ON i.mentor_id = m.instr_id
ORDER BY i.instr_id;

-- Three-table join: who teaches which course, and when
SELECT c.course_id, sec.term, sec.year, i.name AS instructor
FROM section AS sec
JOIN course AS c ON c.course_id = sec.course_id
LEFT JOIN instructor AS i ON i.instr_id = sec.instr_id
WHERE c.dept_id = 'CS'
ORDER BY sec.year, sec.term, c.course_id;
Output
instructor  mentor
----------  --------
Okafor      NULL
Lindqvist   Okafor
Tremblay    Okafor
Nakamura    NULL
Ferreira    Nakamura
Chen        NULL
Haddad      NULL
Morrison    NULL

course_id  term    year  instructor
---------  ------  ----  ----------
CMPUT-174  Fall    2025  Tremblay
CMPUT-204  Fall    2025  Okafor
CMPUT-272  Fall    2025  Nakamura
CMPUT-174  Winter  2026  NULL
CMPUT-201  Winter  2026  Lindqvist
CMPUT-291  Winter  2026  Lindqvist

Worked problem

Where to put a condition in an outer join

You want every department with the number of its students who have at least 50 credits, including departments where that number is zero. A colleague writes LEFT JOIN student s ON s.dept_id = d.dept_id WHERE s.credits >= 50. What goes wrong, and how do you fix it?

Show solution
  1. WHERE runs after the join. For departments with no qualifying students, the left join produces rows whose s.credits is NULL.
  2. NULL >= 50 is unknown, so WHERE removes those rows, and the departments disappear: the outer join has effectively become an inner join.
  3. Move the condition into the join: LEFT JOIN student s ON s.dept_id = d.dept_id AND s.credits >= 50. Now unmatched departments survive with NULLs.
  4. Count COUNT(s.student_id) so that departments with no match show 0.

Answer: Conditions on the optional (right) side of a left join belong in ON, not WHERE; otherwise the unmatched rows are filtered out.

Aggregation and grouping

Aggregate functions compute one value from many rows: COUNT, SUM, AVG, MIN and MAX, plus system-specific ones such as SQLite's group_concat or PostgreSQL's string_agg. Without GROUP BY, an aggregate summarizes the whole result. With GROUP BY, rows are partitioned into groups that share the grouping values, and each group produces one output row. HAVING then filters groups, as WHERE filters rows.

GROUP BY with COUNT(*), COUNT(column), AVG, and HAVING on a group condition
-- Per section: enrolled students, graded students and average grade
SELECT e.sec_id, sec.course_id,
       COUNT(*)             AS enrolled,
       COUNT(e.grade)       AS graded,
       ROUND(AVG(e.grade), 1) AS avg_grade
FROM enrollment AS e
JOIN section AS sec ON sec.sec_id = e.sec_id
GROUP BY e.sec_id, sec.course_id
HAVING COUNT(e.grade) >= 2
ORDER BY avg_grade DESC;

-- Instructors teaching more than one section
SELECT i.name, COUNT(*) AS sections
FROM instructor AS i JOIN section AS sec ON sec.instr_id = i.instr_id
GROUP BY i.instr_id
HAVING COUNT(*) > 1;
Output
sec_id  course_id  enrolled  graded  avg_grade
------  ---------  --------  ------  ---------
3       CMPUT-204  2         2       87.5
6       MATH-125   4         4       81.5
4       CMPUT-272  5         5       79.6
1       CMPUT-174  4         4       77.8
8       BIOL-107   2         2       71.5

name       sections
---------  --------
Lindqvist  2
Ferreira   2

COUNT(*) counts rows and COUNT(e.grade) counts non-NULL grades, which is why enrolled and graded differ for sections with students still in progress. The rule for grouped queries is that every column in SELECT must be either a grouping column or inside an aggregate. A query such as SELECT dept_id, name, COUNT(*) FROM student GROUP BY dept_id is ill-formed, because a department has many student names and SQL does not know which one to show. The standard rejects it, and so do PostgreSQL and MySQL in their default modes, but SQLite accepts it and returns a name from an arbitrary row of the group, a convenience that hides bugs.

Worked problem

Translating a question into a grouped query

For each department that has at least one graded enrollment, find the department name and the highest grade any of its students has earned, listing highest first.

Show solution
  1. Rows needed: enrollments with grades, their students, and the students' departments. Join enrollment to student to department.
  2. Keep only graded enrollments with WHERE e.grade IS NOT NULL (this filters rows, so it belongs in WHERE).
  3. Group by department and compute MAX(e.grade).
  4. SELECT d.name, MAX(e.grade) AS best
    FROM enrollment e
    JOIN student s    ON s.student_id = e.student_id
    JOIN department d ON d.dept_id = s.dept_id
    WHERE e.grade IS NOT NULL
    GROUP BY d.dept_id
    ORDER BY best DESC;
  5. Departments without graded enrollments are excluded automatically by the inner joins and the WHERE condition, as the question asks.

Answer: With the college data: Mathematics 95, Computing Science 93, Biology 81, Physics 77.

Set operations

UNION, INTERSECT and EXCEPT combine the results of two queries with compatible columns. UNION and the others remove duplicates, as in relational algebra; UNION ALL keeps them and is faster because it does not have to check. They are useful when the two parts come from different tables or need different conditions.

UNION of two queries, and EXCEPT for a difference
-- People in Computing Science, whether students or instructors
SELECT name, 'student' AS role FROM student WHERE dept_id = 'CS'
UNION
SELECT name, 'instructor' FROM instructor WHERE dept_id = 'CS'
ORDER BY role, name;

-- Courses that have a section but are nobody's prerequisite
SELECT course_id FROM section
EXCEPT
SELECT prereq_id FROM prereq;
Output
name       role
---------  ----------
Lindqvist  instructor
Okafor     instructor
Tremblay   instructor
Ava        student
Ben        student
Dev        student
Grace      student

course_id
---------
BIOL-107
CMPUT-291
MATH-125
PHYS-130
STAT-151

Note that ORDER BY applies to the whole combined result and is written once at the end. MySQL added INTERSECT and EXCEPT only in version 8.0.31; older code emulates them with joins and subqueries. Set operations compare whole rows, and for this purpose two NULLs are treated as equal, an exception to the usual rule that NULL equals nothing.

Common mistakes

  • MistakeWriting WHERE column = NULL.

    InsteadComparisons with NULL are unknown, so no rows match. Use IS NULL or IS NOT NULL.

  • MistakePutting a condition on an aggregate in WHERE, such as WHERE COUNT(*) > 1.

    InsteadWHERE runs before grouping; conditions on groups go in HAVING.

  • MistakeSelecting a column that is neither grouped nor aggregated.

    InsteadEach output row of a grouped query represents a group, so other columns are ambiguous. SQLite allows it but returns an arbitrary value.

  • MistakeFiltering the right-hand table of a LEFT JOIN in WHERE.

    InsteadThat removes the NULL-extended rows and turns the outer join into an inner join. Put the condition in ON.

  • MistakeRelying on the order rows happen to come back in.

    InsteadWithout ORDER BY the order is undefined and can change. Always sort when order matters.

Summary and key terms

  • A query block is SELECT ... FROM ... WHERE ..., with optional GROUP BY, HAVING, ORDER BY and LIMIT; SQL keeps duplicates unless DISTINCT is used.
  • Logically FROM runs first, then WHERE, GROUP BY, HAVING, SELECT, ORDER BY and LIMIT, which explains where aliases and aggregate conditions may appear.
  • Comparisons with NULL are unknown; WHERE keeps only true rows; test with IS NULL; NOT IN with a NULL returns nothing.
  • Inner joins keep matching pairs; left, right and full outer joins also keep unmatched rows, filling the other side with NULLs.
  • Conditions on the optional side of an outer join belong in ON; table aliases allow self-joins.
  • Aggregates (COUNT, SUM, AVG, MIN, MAX) ignore NULLs except COUNT(*); every non-aggregated SELECT column must be in GROUP BY; HAVING filters groups.
  • UNION, INTERSECT and EXCEPT combine compatible query results and remove duplicates unless ALL is given.
SELECT
The SQL statement and clause that retrieves data and chooses output columns.
WHERE
The clause that keeps rows for which a condition is true.
Three-valued logic
The logic of true, false and unknown that SQL uses because of NULL.
Inner join
A join that keeps only pairs of rows satisfying the join condition.
Outer join
A join that also keeps unmatched rows from one or both tables, padding with NULLs.
Self-join
A join of a table with itself, using different aliases.
Aggregate function
A function such as COUNT or AVG that computes one value from many rows.
GROUP BY
The clause that partitions rows into groups with equal values in the listed columns.
HAVING
The clause that keeps groups satisfying a condition, usually on aggregates.
COALESCE
A function returning its first non-NULL argument.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write a query listing course titles in alphabetical order.

    Show answer

    SELECT title FROM course ORDER BY title;

  2. Why does SELECT name FROM student WHERE dept_id <> 'CS' not return Hiro, whose dept_id is NULL?

    Show answer

    NULL <> 'CS' is unknown, and WHERE keeps only true rows. Add OR dept_id IS NULL to include him.

  3. What is the difference between COUNT(*) and COUNT(grade)?

    Show answer

    COUNT(*) counts rows; COUNT(grade) counts rows where grade is not NULL.

  4. Write a query giving each department's total instructor salary.

    Show answer

    SELECT dept_id, SUM(salary) FROM instructor GROUP BY dept_id;

  5. Change your previous query to show only departments whose total exceeds 200,000.

    Show answer

    Add HAVING SUM(salary) > 200000.

  6. Write a query listing every course with the number of sections offered, including courses with none.

    Show answer

    SELECT c.course_id, COUNT(s.sec_id) FROM course c LEFT JOIN section s ON s.course_id = c.course_id GROUP BY c.course_id;

  7. What does a comma-separated FROM student, department with no WHERE condition produce?

    Show answer

    The Cartesian product: every student paired with every department.

  8. Why might NOT IN (subquery) return no rows unexpectedly?

    Show answer

    If the subquery returns any NULL, every NOT IN comparison is unknown.

  9. Write a query listing each instructor and the number of mentees they have.

    Show answer

    SELECT m.name, COUNT(i.instr_id) FROM instructor m LEFT JOIN instructor i ON i.mentor_id = m.instr_id GROUP BY m.instr_id;

  10. What is the difference between UNION and UNION ALL?

    Show answer

    UNION removes duplicate rows; UNION ALL keeps them and is cheaper.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz