Relational algebra: joins, division and grouping
Writing multi-table queries with theta, natural and outer joins, division, assignment and grouping (γ); solution patterns for “for every” and “for each” questions.
Last week you saw that you can write any query with six fundamental operators. But writing the pattern every time is both long and error-prone. This week brings the shorthands: joins, outer joins, division and grouping (aggregation). Two patterns that earn the most points in the exam are here: multi-table joins and “for every” = division.
Golden rules:
- A natural join equates all columns with the same name. Not only the one you want.
- NULL equals no value. A row with NULL disappears in an inner join; an outer join brings it back.
- The moment you see “every / all”, think division.
- Grouping builds groups only from values that exist. A value that never appears does not show up with 0.
The tables are the sample database from last week: student, course, takes, instructor, department, teaches.
Theta join and equijoin
A theta join is the product and selection written with a single symbol:
can be any condition ( …). If the condition consists only of equalities, it is called an equijoin.
Example: pairs of instructors in the same department where one earns more than the other. Since we join the table with itself, ρ is required:
| i.name | j.name |
|---|---|
| Aksoy | Demir |
| Celik | Sahin |
EE and BIO have only one instructor each, so no pair appears. If you had written instead of , each pair would come twice: (Aksoy, Demir) and (Demir, Aksoy).
Natural join: ⋈
The natural join does three things:
- take ,
- select equality for every pair of columns with the same name,
- keep repeated columns once.
: the only common column is inst_id.
| inst_id | name | dept_name | salary | course_id |
|---|---|---|---|---|
| I1 | Aksoy | CS | 95000 | CS101 |
| I1 | Aksoy | CS | 95000 | CS305 |
| I2 | Demir | CS | 80000 | CS201 |
| I3 | Kaya | EE | 85000 | EE210 |
| I4 | Sahin | MATH | 70000 | MA101 |
| I5 | Celik | MATH | 72000 | MA202 |
6 rows, 5 columns. Yildiz (I6) is not there because she has no match. If there is no common column at all, the natural join turns into a plain product.
PITFALL · a common column you did not want. When you write ,
student.dept_nameandcourse.dept_nameare equated too. The result is 9 records, not 14: only the courses that students took from their own department remain. Ada’s MA101, Cem’s MA101 and CS101, Defne’s CS305, and everything of Gizem’s disappear. Fix: before the join, drop the extra column with , or write an explicit theta condition.
The natural join is commutative and associative: . The column order may change, but the content stays the same.
Chained joins
Instructors who teach the courses of the departments in the Galata building: teaches → course (course_id) → department (dept_name).
Galata has CS and EE. The courses of these departments are taught by I1, I2 and I3.
Outer joins
An inner join drops rows that have no partner. Sometimes that row is exactly what you want: “instructors who teach no course should also appear in the list”. An outer join keeps the unmatched rows and fills the missing columns with NULL.
| Symbol | Name | Preserved |
|---|---|---|
| ⟕ | left outer join | unmatched rows on the left |
| ⟖ | right outer join | unmatched rows on the right |
| ⟗ | full outer join | the ones on both sides |
instructor ⟕ teaches (some of the columns):
| inst_id | name | course_id |
|---|---|---|
| I1 | Aksoy | CS101 |
| I1 | Aksoy | CS305 |
| I2 | Demir | CS201 |
| I3 | Kaya | EE210 |
| I4 | Sahin | MA101 |
| I5 | Celik | MA202 |
| I6 | Yildiz | NULL |
teaches ⟖ course, on the other hand, brings BI100 (inst_id = NULL). A right outer join is a left outer join with the sides swapped: R ⟖ S = S ⟕ R (except for column order).
student ⟗ department (on dept_name):
| stu_id | name | dept_name | building |
|---|---|---|---|
| S1 | Ada | CS | Galata |
| S2 | Bora | CS | Galata |
| S3 | Cem | EE | Galata |
| S4 | Defne | MATH | Rumeli |
| S5 | Ece | CS | Galata |
| S6 | Firat | EE | Galata |
| S7 | Gizem | NULL | NULL |
| NULL | NULL | BIO | Kadikoy |
Gizem is unmatched on the left (NULL department), BIO is unmatched on the right (it has no students). 6 + 1 + 1 = 8 rows.
PITFALL · SQL dialects. Standard SQL has
FULL OUTER JOIN. SQLite has supported it since version 3.39; older SQLite and MySQL do not. In that case you imitate it by taking a left outer join and aUNIONwith the unmatched rows on the right side. The result on this page was verified both ways.
Division: ÷
Division answers the question: “Which A values are matched with every value in B?” The result of is the values for which holds for every in .
Example: students who took all the CS courses.
The divisor is {CS101, CS201, CS305}. Result: S1. Bora is eliminated because he did not take CS305, and Ece because she did not take CS201 and CS305.
Deriving it from the fundamental operators
Division is not fundamental:
The idea: everyone minus “those who are missing at least one”. Step by step (, = the CS courses):
1) π_stu_id(T) = {S1, S2, S3, S4, S5, S7} candidate students
2) π_stu_id(T) × CS = 6 · 3 = 18 rows if everyone had taken every CS course
3) (2) − T = 10 rows the missing (student, course) pairs
S2-CS305 S3-CS201 S3-CS305 S4-CS101 S4-CS201
S5-CS201 S5-CS305 S7-CS101 S7-CS201 S7-CS305
4) π_stu_id(3) = {S2, S3, S4, S5, S7} those with something missing
5) (1) − (4) = {S1}
You write the same query in SQL with a double NOT EXISTS (“a student for whom there is no CS course they did not take”). You will see it in week 7.
PITFALL · the columns of the dividend. should have only the columns and . If you write and leave
gradein, becomes (stu_id, grade) and the query asks something completely different. Always narrow with before the division.
Assignment: ←
To break up long expressions, you define temporary relations:
cs_courses ← π_course_id(σ_{dept_name='CS'}(course))
enrol ← π_stu_id,course_id(takes)
result ← enrol ÷ cs_courses
Assignment adds no new power; it adds readability. Using it in complex exam questions does not cost points, and most of the time it keeps you from making mistakes.
Generalized projection
Generalized projection allows arithmetic expressions in the π list. A 10% raise for the MATH instructors:
Result: Sahin 77000, Celik 79200.
Grouping and aggregate functions: 𝒢
Aggregate functions produce a single value from a set of values: count, sum, avg, min, max. The grouping symbol:
The subscript on the left holds the grouping columns, and on the right are the aggregate functions. Some sources use the letter , with the same meaning.
Average salary and number of instructors per department:
| dept_name | avg(salary) | count(inst_id) |
|---|---|---|
| CS | 87500 | 2 |
| EE | 85000 | 1 |
| MATH | 71000 | 2 |
| BIO | 60000 | 1 |
If you write no grouping column, the whole table becomes one group: = (462000, 95000).
Rules:
- Aggregate functions skip NULL values. = 6, while the table has 7 rows.
- For a condition on the group result (HAVING in SQL), you write σ on top of the grouping. For this, name the aggregate result with
as: → CS, EE. - No group is created for a value that does not appear in the input.
Which operator for which question
| Phrase in the question | Pattern |
|---|---|
| “… with their department’s building” | ⋈ (natural or equi) |
| “including those who have none” | outer join |
| “pairs of … in the same …” | self theta join with ρ |
| “took every / all …” | ÷ |
| “never / no …” | difference |
| “for each department, the number of …” | |
| “departments where the average … exceeds” | 𝒢 then σ |
Common mistakes
| Wrong idea | The truth |
|---|---|
| ⋈ equates only the key column | It equates all columns with the same name |
| gives all records | dept_name is matched too, 9 rows remain |
| The number of rows of an outer join = the number of rows of the left table | A row can match many times; Aksoy appears twice |
| NULL = NULL matches | It does not; Gizem matched no department |
| Solving an “every” question with ∩ | The size of the divisor set can change; use ÷ |
| Skipping π before division | Extra columns get mixed into and the result breaks |
| Grouping also shows a group with count 0 | A value that never appears does not show up (no BI100) |
| count(A) is the number of rows | It skips NULLs; use count(*) for rows |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
How many tuples are in the result of instructor ⟕ teaches (left outer join on inst_id)?
Show answer
The inner join gives 6 rows (Aksoy appears twice because he teaches two courses). The left outer join also adds the unmatched left rows with NULL. The only instructor who teaches no course is I6 (Yildiz): 6 + 1 = 7.
Write a relational algebra expression for the names of instructors who teach CS305. Give the result.
Show answer
and join naturally on their only common column, inst_id. Then the row condition, and the projection last:. Only I1 teaches CS305: Aksoy.
How many tuples does (natural joins) return on the sample data? Why is it not 14?
Show answer
gives 14 rows on the common column stu_id. But the columns common with course are course_id and dept_name. The natural join equates both, so only the courses from the student's own department remain: Ada 3, Bora 2, Cem 1 (EE210), Defne 2, Ece 1. Total 9. Gizem's department is NULL, so she never matches. If you want all the records, first narrow course with .
Write a relational algebra expression for the IDs of students who took every MATH course. Give the result.
Show answer
The “every / all” pattern is division. The divisor: the MATH courses {MA101, MA202}..
Students who took MA101: S1, S3, S4. Students who took MA202: S4, S7. Only S4 took both.
Give the result of . Does BI100 appear?
Show answer
The takes rows are grouped by course_id, and the rows in each group are counted. CS101: S1, S2, S3, S5 = 4. CS201: 2. CS305: 2. EE210: 1. MA101: S1, S3, S4 = 3. MA202: 2.
Grouping creates groups only from values that exist in the input. BI100 does not appear in takes at all, so it does not show up even with 0. If you want to see 0, you must do an outer join first.
How many students in CS101 have a grade, i.e. what is ?
Show answer
There are 4 records in CS101: S1 (A), S2 (B), S3 (NULL), S5 (F). count(grade) does not count NULL values: 3. If you wanted the number of rows, you would get 4 with count(stu_id) or with COUNT(*) in SQL.
Write a relational algebra expression for the departments that have no instructor teaching any course. Give the result.
Show answer
The departments of instructors who teach: = {CS, EE, MATH}. Subtract from all departments: BIO. BIO has the instructor Yildiz but she teaches nothing, so BIO is in the result.
How many tuples does student ⟗ department (full outer join on dept_name) have?
Show answer
Matching rows: the 6 students who have a department (S1..S6), each with their own department. Unmatched left row: Gizem (dept_name is NULL, and NULL equals nothing). Unmatched right row: BIO (it has no students). 6 + 1 + 1 = 8.
Write a relational algebra expression for the IDs of courses taken by all CS students. Give the result.
Show answer
This time “every student” is on the divisor side: the CS students {S1, S2, S5}. The dividend is , and the result is course_id.
CS101 was taken by S1, S2, S5: it passes. S5 did not take CS201, and S2 and S5 did not take CS305. Result: CS101.
Give the result of .
Show answer
The credits of the course are attached to each record, then summed per student. S1: 4+4+3+4 = 15. S2: 4+4 = 8. S3: 3+4+4 = 11. S4: 4+3+3 = 10. S5: 4. S7: 3. S6 has no enrollment, so it has no group.
We narrowed course with . Here the only common column between takes and course is already course_id, but as soon as student is added to the expression, dept_name causes trouble. Make narrowing a habit.
What is ? Compare with the number of tuples in .
Show answer
count(A) counts only the non-NULL values of A. Gizem's department is NULL and is not counted: 6. The table has 7 rows. In SQL, COUNT(dept_name) = 6 and COUNT(*) = 7.
Write an expression for the departments whose average instructor salary is greater than 75000. Give the result.
Show answer
First group and name the average: → CS 87500, EE 85000, MATH 71000, BIO 60000.
Then a selection on the group result: , and on the outside. The result is CS and EE. This is the algebra counterpart of HAVING in SQL.
Verification: All result tables were computed in verify/SEN2104/05.mjs with lib.mjs (njoin, theta, leftOuter, rightOuter, fullOuter, divide, gamma); division was also built step by step from the fundamental operators. The SQL equivalent of every expression was run on node:sqlite (SQLite 3.53, with FULL OUTER JOIN support) and gave the same result (56 checks, 0 errors).