Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 4 – Relational algebra and relational calculus

Joins in relational algebra and join sizes

Theta, natural, outer, semi and anti joins built from the core operators, the size bounds that keys give, and every join checked in SQL.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Express theta joins, equi-joins, natural joins and the three outer joins in relational algebra
  • Use semijoin and antijoin for 'exists' and 'does not exist' questions
  • Compute minimum and maximum result sizes of joins from keys and foreign keys
  • Write the SQL equivalent of each join and predict its output

Before you start

What you’ll learn · Before you start

What you will learn

  • Express theta joins, equi-joins, natural joins and the three outer joins in relational algebra
  • Use semijoin and antijoin for 'exists' and 'does not exist' questions
  • Compute minimum and maximum result sizes of joins from keys and foreign keys
  • Write the SQL equivalent of each join and predict its output

Before you start

On this page

A join combines two relations by pairing the tuples that belong together, and in relational algebra it is a Cartesian product followed by a selection. The theta join R ⋈θ S keeps the pairs of the product that satisfy a condition θ, an equi-join is a theta join whose condition only tests equality, and the natural join R ⋈ S tests equality on every attribute the two relations share and keeps one copy of each. Three outer joins (left, right and full) also keep the tuples that found no partner, padded with NULL. The semijoin R ⋉ S keeps the tuples of R that have a partner in S, and the antijoin R ▷ S keeps those that have none: they answer “exists” and “does not exist” questions. Keys and foreign keys fix how large a join can be, which is what exam questions and query planners both need. This lesson builds each join from the core operators, checks the size rules on every small instance, and runs every join in SQL.

The join family at a glance

Every join here is shorthand for operators of the previous lesson, and the chain below shows which:

The joins built from the core operators: product, θ-join, equi-join and natural join, then semijoin, antijoin and outer joins.R × Severy pair of tuplesθ-join R ⋈θ S= σ θ (R × S)equi-joinθ uses only =natural join R ⋈ S= on every common name,one copy of eachouter joins ⟕ ⟖ ⟗= join + the tupleswithout a partner,padded with NULLsemijoin R ⋉ S= π attributes of R (R ⋈ S)antijoin R ▷ S= R − (R ⋉ S)

Every join is built from the core operators

Text description of the diagram

The diagram is a chain of boxes from top to bottom, with one branch.

  1. R × S, every pair of tuples.
  2. Below it, the θ-join R ⋈θ S, which is σ θ (R × S).
  3. From the θ-join, one arrow leads to the equi-join, a θ-join whose condition uses only =, and another to the outer joins ⟕, ⟖ and ⟗: the join plus the tuples without a partner, padded with NULL.
  4. Below the equi-join, the natural join R ⋈ S: equality on every common attribute name, with one copy of each.
  5. Below the natural join, the semijoin R ⋉ S, which keeps the attributes of R: π attributes of R (R ⋈ S).
  6. At the bottom, the antijoin R ▷ S, which is R − (R ⋉ S).

The examples use the sample college again: Student(sid, name, branch), Course(cid, title, dept, credits) and Enrolled(sid, cid, grade), with sid the key of Student, cid the key of Course, and (sid, cid) the key of Enrolled, whose sid and cid are foreign keys to the other two. Farhan has no enrolment, nobody takes CS301, Dev studies ME, and no department offers ME courses.

From product to theta join

The theta join of R and S on a condition θ is the selection of θ over their product:

R⋈θS=σθ(R×S)R \bowtie_{\theta} S = \sigma_{\theta}(R \times S)

θ may compare any attribute of R with any attribute of S using =, ≠, <, ≤, > or ≥. An equi-join uses only =, which is by far the most common case: a foreign key equal to the key it refers to. Pairing every student with the courses offered by the department of the same name is an equi-join on branch = dept, and it has 11 tuples: 3 CSE students with 3 CSE courses, 2 ECE students with 1 ECE course, and Dev with nothing, because no department offers ME courses.

The definition says what the result contains, not how to compute it. Building the product first would cost |R| × |S| pairs, and database engines avoid that with join algorithms, such as hash joins and sort-merge joins, which find the matching pairs directly. A later module of this track covers how they work.

The natural join

The natural join R ⋈ S needs no condition: it equates every attribute name that R and S have in common, and keeps one copy of each. For Student(sid, name, branch) and Enrolled(sid, cid, grade), whose only common name is sid, the definition expands to a rename, a product, a selection and a projection:

Student⋈Enrolled=πL(σsid=E.sid(Student×ρE(Enrolled)))L=sid, name, branch, cid, grade\begin{aligned} \text{Student} \bowtie \text{Enrolled} &= \pi_{L}\big(\sigma_{\text{sid} = \text{E.sid}}(\text{Student} \times \rho_{E}(\text{Enrolled}))\big) \\ L &= \text{sid, name, branch, cid, grade} \end{aligned}

Two special cases follow from the definition and are favourites of exam questions. With no common attribute, there is no equality to test, so every pair qualifies and the natural join is the product. With all attributes in common, a pair qualifies only when the two tuples are equal, so the natural join is the intersection. The run shows both: 36 tuples for Student ⋈ Course, which share no name, and 3 for two one-attribute relations of student ids.

The θ-join, the outer joins and three natural joins SQL · joins.sql
CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
CREATE TABLE enrolled (
  sid INTEGER NOT NULL REFERENCES student (sid),
  cid TEXT NOT NULL REFERENCES course (cid),
  grade TEXT,
  PRIMARY KEY (sid, cid)
);
INSERT INTO student VALUES
  (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
  (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
INSERT INTO course VALUES
  ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
  ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
  ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
INSERT INTO enrolled VALUES
  (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
  (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
  (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');

-- A θ-join on branch = dept, and the three outer joins with the same condition.
SELECT 'inner' AS kind, count(*) AS rows_out FROM student s JOIN course c ON s.branch = c.dept
UNION ALL SELECT 'left', count(*) FROM student s LEFT JOIN course c ON s.branch = c.dept
UNION ALL SELECT 'right', count(*) FROM student s RIGHT JOIN course c ON s.branch = c.dept
UNION ALL SELECT 'full', count(*) FROM student s FULL JOIN course c ON s.branch = c.dept;

-- The rows only the full outer join adds: tuples without a partner, padded with NULL.
SELECT s.name, s.branch, c.cid, c.dept
FROM student s FULL JOIN course c ON s.branch = c.dept
WHERE s.sid IS NULL OR c.cid IS NULL;

-- Natural joins: on sid (the only common name), on no common name, and on every name.
SELECT
  (SELECT count(*) FROM student NATURAL JOIN enrolled) AS on_sid,
  (SELECT count(*) FROM student NATURAL JOIN course) AS no_common_name,
  (SELECT count(*) FROM (SELECT sid FROM enrolled WHERE cid = 'CS201')
                   NATURAL JOIN (SELECT sid FROM enrolled WHERE cid = 'MA101')) AS all_names_common;

Output

┌───────┬──────────┐
│ kind  │ rows_out │
├───────┼──────────┤
│ inner │ 11       │
│ left  │ 12       │
│ right │ 13       │
│ full  │ 14       │
└───────┴──────────┘
┌──────┬────────┬───────┬──────┐
│ name │ branch │  cid  │ dept │
├──────┼────────┼───────┼──────┤
│ Dev  │ ME     │       │      │
│      │        │ MA101 │ MATH │
│      │        │ HS101 │ HSS  │
└──────┴────────┴───────┴──────┘
┌────────┬────────────────┬──────────────────┐
│ on_sid │ no_common_name │ all_names_common │
├────────┼────────────────┼──────────────────┤
│ 12     │ 36             │ 3                │
└────────┴────────────────┴──────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < joins.sql

SQL has NATURAL JOIN too. SQLite’s documentation explains it as an implicit USING clause listing every column name that the two inputs share, so the join condition is decided by the column names at the moment the query runs. That is convenient and fragile. PostgreSQL’s manual warns that a schema change that adds a matching column name to either side “will cause the join to combine that new column as well”. Here two audit columns of the same name turn a 12-row join into an empty one:

One new column, and NATURAL JOIN returns nothing SQL · natural_trap.sql
CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
CREATE TABLE enrolled (sid INTEGER NOT NULL, cid TEXT NOT NULL, grade TEXT, PRIMARY KEY (sid, cid));
INSERT INTO student VALUES
  (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
  (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
INSERT INTO enrolled VALUES
  (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
  (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
  (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');

SELECT count(*) AS before_change FROM student NATURAL JOIN enrolled;

-- Later, a change adds an audit column of the same name to both tables.
ALTER TABLE student ADD COLUMN updated_by TEXT NOT NULL DEFAULT 'import';
ALTER TABLE enrolled ADD COLUMN updated_by TEXT NOT NULL DEFAULT 'portal';

SELECT
  (SELECT count(*) FROM student NATURAL JOIN enrolled) AS natural_after,
  (SELECT count(*) FROM student JOIN enrolled USING (sid)) AS using_after;

Output

┌───────────────┐
│ before_change │
├───────────────┤
│ 12            │
└───────────────┘
┌───────────────┬─────────────┐
│ natural_after │ using_after │
├───────────────┼─────────────┤
│ 0             │ 12          │
└───────────────┴─────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < natural_trap.sql

JOIN ... USING (sid) names the join column, so it kept its 12 rows. In application code, prefer USING or an ON condition, which keep working when somebody adds a column.

Outer joins

An inner join drops every tuple that has no partner. The outer joins keep them:

  • Left outer join R ⟕ S: every tuple of R appears, joined with its partners or, if it has none, once with NULL in the attributes of S.
  • Right outer join R ⟖ S: the mirror image, every tuple of S appears.
  • Full outer join R ⟗ S: the tuples without a partner on both sides appear, each padded with NULL.

NULL is not a value of any domain of the basic algebra, so the outer joins are extensions of it. In the algebra, the left outer join is the join plus the tuples of R that did not take part in it, extended with nulls:

R⟕S=(R⋈S)∪(D×N)D=R−πR(R⋈S)N={(null,…,null)}\begin{aligned} R \mathbin{⟕} S &= (R \bowtie S) \cup (D \times N) \\ D &= R - \pi_{R}(R \bowtie S) \\ N &= \{(\text{null}, \ldots, \text{null})\} \end{aligned}

Here π R means the projection on the attributes of R, so D holds the tuples of R that found no partner, and N is a relation with a single tuple of nulls, one for each attribute of S that R does not have. On branch = dept, the run above found 11 inner rows, 12 left (Dev added), 13 right (MA101 and HS101 added, because no branch is called MATH or HSS) and 14 full, and it listed the three padded rows, in which the output shows each NULL as an empty cell. SQLite has supported RIGHT and FULL OUTER JOIN since version 3.39.0, so the browser’s SQLite 3.49.1 runs all four.

The place of a condition matters in an outer join. PostgreSQL’s manual explains the rule: a condition in ON takes effect before the join pads the rows that found no partner, and a condition in WHERE takes effect after it, which changes nothing for an inner join and a great deal for an outer one. For “every student with their CS201 grade”, the condition on cid belongs in ON:

The same condition in ON and in WHERE SQL · on_vs_where.sql
CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
CREATE TABLE enrolled (
  sid INTEGER NOT NULL REFERENCES student (sid),
  cid TEXT NOT NULL REFERENCES course (cid),
  grade TEXT,
  PRIMARY KEY (sid, cid)
);
INSERT INTO student VALUES
  (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
  (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
INSERT INTO course VALUES
  ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
  ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
  ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
INSERT INTO enrolled VALUES
  (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
  (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
  (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');

-- Every student with their CS201 grade: the condition on cid in ON keeps all six students.
SELECT s.name, e.grade AS cs201_grade
FROM student s LEFT JOIN enrolled e ON e.sid = s.sid AND e.cid = 'CS201'
ORDER BY s.sid;

-- The same condition in WHERE runs after the join and removes the rows padded with NULL.
SELECT s.name, e.grade AS cs201_grade
FROM student s LEFT JOIN enrolled e ON e.sid = s.sid
WHERE e.cid = 'CS201'
ORDER BY s.sid;

Output

┌────────┬─────────────┐
│  name  │ cs201_grade │
├────────┼─────────────┤
│ Asha   │ A           │
│ Bilal  │             │
│ Chitra │ B           │
│ Dev    │             │
│ Esha   │ A           │
│ Farhan │             │
└────────┴─────────────┘
┌────────┬─────────────┐
│  name  │ cs201_grade │
├────────┼─────────────┤
│ Asha   │ A           │
│ Chitra │ B           │
│ Esha   │ A           │
└────────┴─────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < on_vs_where.sql

In ON, the condition limits which enrolments may join, and the students without a CS201 enrolment are still padded and kept: 6 rows. In WHERE, it runs on the joined rows, and e.cid = ‘CS201’ is not true when e.cid is NULL, so the padded rows disappear: 3 rows, the same as an inner join. With inner joins, the two placements give the same result.

Semijoin and antijoin

Some questions ask only whether a partner exists. “Which students are enrolled in something?” wants each such student once, however many courses they take. The semijoin keeps the tuples of R that have at least one partner in S, with R’s attributes only, and the antijoin keeps those with none:

R⋉S=πR(R⋈S)R▷S=R−(R⋉S)\begin{aligned} R \ltimes S &= \pi_{R}(R \bowtie S) \\ R \mathbin{▷} S &= R - (R \ltimes S) \end{aligned}

A semijoin never has more tuples than R, which a join can easily have. In SQL, EXISTS and NOT EXISTS write them directly, and PostgreSQL’s manual notes that an EXISTS test “produces at most one output row for each” row of the outer table, even when several rows match:

Semijoin and antijoin, and NOT IN with a NULL SQL · semi_anti.sql
CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
CREATE TABLE enrolled (
  sid INTEGER NOT NULL REFERENCES student (sid),
  cid TEXT NOT NULL REFERENCES course (cid),
  grade TEXT,
  PRIMARY KEY (sid, cid)
);
INSERT INTO student VALUES
  (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
  (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
INSERT INTO course VALUES
  ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
  ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
  ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
INSERT INTO enrolled VALUES
  (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
  (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
  (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');

-- A join repeats a student once per enrolment; a semijoin (EXISTS) keeps each student once.
SELECT
  (SELECT count(*) FROM student s JOIN enrolled e ON e.sid = s.sid) AS join_rows,
  (SELECT count(*) FROM student s WHERE EXISTS (SELECT 1 FROM enrolled e WHERE e.sid = s.sid)) AS semijoin_rows;

-- An antijoin (NOT EXISTS): the students with no enrolment.
SELECT name FROM student s
WHERE NOT EXISTS (SELECT 1 FROM enrolled e WHERE e.sid = s.sid);

-- A waiting list whose form once saved an empty student id.
CREATE TABLE waitlist (sid INTEGER, cid TEXT);
INSERT INTO waitlist VALUES (2, 'CS301'), (NULL, 'CS301');

-- Students not on the waiting list, written two ways.
SELECT
  (SELECT count(*) FROM student s WHERE NOT EXISTS (SELECT 1 FROM waitlist w WHERE w.sid = s.sid)) AS not_exists_rows,
  (SELECT count(*) FROM student WHERE sid NOT IN (SELECT sid FROM waitlist)) AS not_in_rows;

Output

┌───────────┬───────────────┐
│ join_rows │ semijoin_rows │
├───────────┼───────────────┤
│ 12        │ 5             │
└───────────┴───────────────┘
┌────────┐
│  name  │
├────────┤
│ Farhan │
└────────┘
┌─────────────────┬─────────────┐
│ not_exists_rows │ not_in_rows │
├─────────────────┼─────────────┤
│ 5               │ 0           │
└─────────────────┴─────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < semi_anti.sql

The join repeated a student once per enrolment, 12 rows, while the semijoin kept each of the 5 enrolled students once. The antijoin found Farhan. The last two counts are the trap: on a waiting list that contains a NULL student id, NOT EXISTS finds the 5 students who are not on the list, and NOT IN finds none. For a student with sid 4, NOT IN must establish 4 ≠ 2 and 4 ≠ NULL; the second comparison is unknown, so the whole condition is unknown, and WHERE keeps a row only when its condition is true. PostgreSQL’s manual states the same rule for NOT IN: with no equal value in the list and at least one null in it, the result is null instead of true. Write antijoins with NOT EXISTS, or make sure the column of the subquery cannot be NULL.

How big can a join be?

Exam questions, and the planners that choose join orders, need the size of a join before it is computed. For R(a, b) with r tuples and S(b, c) with s tuples, joined on b, the bounds depend on what the keys say:

What is known Size of R ⋈ S, and why
Nothing 0 to r × s: no pair may match, or every pair may share one b
b is a key of S 0 to r: each tuple of R finds at most one partner
b is a key of S, and R.b is a non-null foreign key to it Exactly r: each tuple of R finds exactly one partner
b is a key of both R and S 0 to min(r, s): each b value occurs at most once on each side
No common attribute (natural join) Exactly r × s: it is the product

The outer joins add the tuples without a partner, so R ⟕ S has between r and r × s tuples (when s is at least 1), and R ⟗ S between max(r, s) and the larger of r × s and r + s. The last bound surprises people: when no b matches, the full outer join has r + s tuples, which is more than r × s when one side has a single tuple.

Instead of trusting the table, the program below checks it. It builds every R with r tuples and every S with s tuples over small domains, joins each pair, and records the smallest and largest result under each condition:

Every instance of R(a, b) and S(b, c), for three sizes Python · join_sizes.py
"""Check the join-size bounds by trying every small instance of R(a, b) and S(b, c)."""
from itertools import combinations, product

A, B, C = (1, 2, 3), ("v", "w", "x", "y", "z"), (1, 2, 3)


def sizes(R, S):
    """The sizes of R ⋈ S, R ⟕ S and R ⟗ S, joining on the common attribute b."""
    inner = sum(1 for (_, b) in R for (b2, _) in S if b == b2)
    lonely_r = sum(1 for (_, b) in R if all(b != b2 for (b2, _) in S))
    lonely_s = sum(1 for (b, _) in S if all(b != b2 for (_, b2) in R))
    return inner, inner + lonely_r, inner + lonely_r + lonely_s


def distinct_b(rel, position):
    values = [t[position] for t in rel]
    return len(values) == len(set(values))


ROWS = [
    # label, bound as text, bound as numbers (r, s), which size, which instances count
    ("R ⋈ S, no constraint", "0 to r·s", lambda r, s: (0, r * s), 0, lambda R, S: True),
    ("R ⋈ S, b a key of S", "0 to r", lambda r, s: (0, r), 0, lambda R, S: distinct_b(S, 0)),
    ("R ⋈ S, b a key of S, R.b references S", "exactly r", lambda r, s: (r, r), 0,
     lambda R, S: distinct_b(S, 0) and {b for (_, b) in R} <= {b for (b, _) in S}),
    ("R ⋈ S, b a key of R and of S", "0 to min(r, s)", lambda r, s: (0, min(r, s)), 0,
     lambda R, S: distinct_b(R, 1) and distinct_b(S, 0)),
    ("R ⟕ S, no constraint", "r to r·s", lambda r, s: (r, r * s), 1, lambda R, S: True),
    ("R ⟗ S, no constraint", "max(r, s) to max(r·s, r + s)", lambda r, s: (max(r, s), max(r * s, r + s)), 2,
     lambda R, S: True),
]
SHAPES = [(3, 2), (2, 3), (1, 2)]

observed = {}  # (row, shape) -> [min, max]
pairs = 0
for r, s in SHAPES:
    for R in combinations(list(product(A, B)), r):
        for S in combinations(list(product(B, C)), s):
            pairs += 1
            got = sizes(R, S)
            for i, (_, _, _, which, counts) in enumerate(ROWS):
                if counts(R, S):
                    lo_hi = observed.setdefault((i, (r, s)), [got[which], got[which]])
                    lo_hi[0], lo_hi[1] = min(lo_hi[0], got[which]), max(lo_hi[1], got[which])

print(f"{pairs:,} pairs of relations tried; each cell is the smallest to the largest size seen")
print((f"{'':39} {'bound':29}" + "".join(f"{f'r={r} s={s}':11}" for r, s in SHAPES)).rstrip())
all_match = True
for i, (label, text, bound, _, _) in enumerate(ROWS):
    cells = ""
    for r, s in SHAPES:
        lo, hi = observed[(i, (r, s))]
        all_match &= (lo, hi) == bound(r, s)
        cells += f"{f'{lo} to {hi}':11}"
    print(f"{label:39} {text:29}{cells}".rstrip())
print("every range seen equals its bound:", all_match)

Output

97,125 pairs of relations tried; each cell is the smallest to the largest size seen
                                        bound                        r=3 s=2    r=2 s=3    r=1 s=2
R ⋈ S, no constraint                    0 to r·s                     0 to 6     0 to 6     0 to 2
R ⋈ S, b a key of S                     0 to r                       0 to 3     0 to 2     0 to 1
R ⋈ S, b a key of S, R.b references S   exactly r                    3 to 3     2 to 2     1 to 1
R ⋈ S, b a key of R and of S            0 to min(r, s)               0 to 2     0 to 2     0 to 1
R ⟕ S, no constraint                    r to r·s                     3 to 6     2 to 6     1 to 2
R ⟗ S, no constraint                    max(r, s) to max(r·s, r + s) 3 to 6     3 to 6     2 to 3
every range seen equals its bound: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 join_sizes.py

Each range that the 97,125 pairs of relations produced is exactly the bound, including the case r = 1, s = 2, where the full outer join reaches r + s = 3 while r × s is 2. On the sample college, the foreign-key row of the table predicts that Enrolled ⋈ Student has exactly 12 tuples, one per enrolment, and that adding Course keeps it at 12, because cid is the key of Course and every enrolment’s cid refers to it. The left outer join of Student with Enrolled should have 13: the 12 enrolments, and Farhan padded with NULL.

The predicted sizes on the sample college SQL · sample_sizes.sql
CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
CREATE TABLE enrolled (
  sid INTEGER NOT NULL REFERENCES student (sid),
  cid TEXT NOT NULL REFERENCES course (cid),
  grade TEXT,
  PRIMARY KEY (sid, cid)
);
INSERT INTO student VALUES
  (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
  (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
INSERT INTO course VALUES
  ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
  ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
  ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
INSERT INTO enrolled VALUES
  (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
  (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
  (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');


-- The sizes the key and foreign-key rules predict, on the sample college.
SELECT
  (SELECT count(*) FROM enrolled) AS enrolments,
  (SELECT count(*) FROM enrolled JOIN student USING (sid)) AS with_student,
  (SELECT count(*) FROM enrolled JOIN student USING (sid) JOIN course USING (cid)) AS with_course_too,
  (SELECT count(*) FROM student LEFT JOIN enrolled USING (sid)) AS student_left_join;

Output

┌────────────┬──────────────┬─────────────────┬───────────────────┐
│ enrolments │ with_student │ with_course_too │ student_left_join │
├────────────┼──────────────┼─────────────────┼───────────────────┤
│ 12         │ 12           │ 12              │ 13                │
└────────────┴──────────────┴─────────────────┴───────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < sample_sizes.sql

Read the other way, the table is also a test: a join of R with S that returns more than |R| tuples proves that the join attribute is not a key of S.

Join sizes in GATE CS

The relational model topics in Section 9 of the GATE 2027 Computer Science syllabus include relational algebra, and the size of a join is a typical numerical answer: two relations, their sizes, a key or a foreign key, and the number of tuples to find. Find the attribute the join tests, check whether it is a key on either side and whether a non-null foreign key guarantees a partner, and read the bound from the table above.

SQLite Online (SQL Playground) Run joins.sql, then change the ON condition of the full outer join and watch which rows get padded with NULL. Join CSV/Excel Files (VLOOKUP Online) Join two spreadsheets on a key column and compare the matched and unmatched rows with an inner and a full outer join.

Exercises

Exercise · Easy · SQL

Every course with its A-grade students

The tables are the lesson's: student(sid, name, branch), course(cid, title, dept, credits) and enrolled(sid, cid, grade).

Write one query that returns two columns, cid and name: one row for every student who got grade A in a course, with that course's cid. Every course must appear, also the courses in which nobody got an A: for those, return one row with the cid and NULL as the name.

The order of the rows does not matter. The sample tests include a course whose students all got B, and a course with no enrolments at all.

Starter code · a_grades.sql

-- Every course, with the name of each student who got an A in it (NULL when nobody did).
SELECT c.cid, s.name
FROM course c
JOIN enrolled e ON e.cid = c.cid
JOIN student s ON s.sid = e.sid
WHERE e.grade = 'A';
The sample tests · tests.yaml
# Sample tests: each one builds the three tables, runs your query on them and compares the rows it returns (in any
# order).
tests:
  - name: lists each A with its course, and a course without any A once, with NULL
    setup: |
      CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
      CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
      CREATE TABLE enrolled (sid INTEGER NOT NULL, cid TEXT NOT NULL, grade TEXT, PRIMARY KEY (sid, cid));
      INSERT INTO student VALUES
        (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
        (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
      INSERT INTO course VALUES
        ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
        ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
        ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
      INSERT INTO enrolled VALUES
        (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
        (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
        (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');
    expected:
      columns: [cid, name]
      rows:
        - [CS201, Asha]
        - [CS201, Esha]
        - [CS202, Esha]
        - [CS301, null]
        - [MA101, Asha]
        - [MA101, Chitra]
        - [EC210, Bilal]
        - [HS101, Esha]
  - name: keeps a course whose students all got B, and a course nobody took
    setup: |
      CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
      CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
      CREATE TABLE enrolled (sid INTEGER NOT NULL, cid TEXT NOT NULL, grade TEXT, PRIMARY KEY (sid, cid));
      INSERT INTO student VALUES (10, 'Ravi', 'ECE'), (11, 'Meena', 'CSE'), (12, 'Kiran', 'ME');
      INSERT INTO course VALUES
        ('CS250', 'Computer Networks', 'CSE', 4), ('ME110', 'Mechanics', 'ME', 3), ('HS102', 'Ethics', 'HSS', 2);
      INSERT INTO enrolled VALUES (10, 'CS250', 'B'), (11, 'CS250', 'B'), (12, 'ME110', 'A'), (11, 'ME110', 'A');
    expected:
      columns: [cid, name]
      rows:
        - [CS250, null]
        - [ME110, Kiran]
        - [ME110, Meena]
        - [HS102, null]
  - name: does not repeat a course that has both A and other grades
    setup: |
      CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
      CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
      CREATE TABLE enrolled (sid INTEGER NOT NULL, cid TEXT NOT NULL, grade TEXT, PRIMARY KEY (sid, cid));
      INSERT INTO student VALUES (20, 'Tara', 'CSE'), (21, 'Imran', 'CSE'), (22, 'Joseph', 'ECE');
      INSERT INTO course VALUES ('CS201', 'Databases', 'CSE', 4);
      INSERT INTO enrolled VALUES (20, 'CS201', 'A'), (21, 'CS201', 'C'), (22, 'CS201', NULL);
    expected:
      columns: [cid, name]
      rows:
        - [CS201, Tara]
A hint

Start from course and keep every one of its rows with LEFT JOIN. The condition on the grade decides which enrolments may join: written in the WHERE clause, it runs after the join and throws away the padded rows again.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · SQL

Students with no CSE course, even with a broken row

The tables are the lesson's: student(sid, name, branch), course(cid, title, dept, credits) and enrolled(sid, cid, grade), but enrolled was loaded from an old spreadsheet and has no constraints: some of its rows may have an empty (NULL) sid.

Write one query that returns a single column, name: the names of the students who are not enrolled in any course of the CSE department. Students with no enrolments at all count too. Return each student once; the order does not matter.

The sample tests include an enrolment with a NULL student id, which must not change the answer.

Starter code · no_cse_course.sql

-- The students who are not enrolled in any CSE course.
SELECT name
FROM student
WHERE sid NOT IN (
  SELECT e.sid FROM enrolled e JOIN course c ON c.cid = e.cid WHERE c.dept = 'CSE'
);
The sample tests · tests.yaml
# Sample tests: each one builds the three tables, runs your query on them and compares the rows it returns (in any
# order).
tests:
  - name: finds the students without a CSE course in the lesson's data
    setup: |
      CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
      CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
      CREATE TABLE enrolled (sid INTEGER, cid TEXT, grade TEXT);
      INSERT INTO student VALUES
        (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
        (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
      INSERT INTO course VALUES
        ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
        ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
        ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
      INSERT INTO enrolled VALUES
        (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
        (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
        (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A');
    expected:
      columns: [name]
      rows:
        - [Bilal]
        - [Dev]
        - [Farhan]
  - name: gives the same answer when one enrolment has a NULL student id
    setup: |
      CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
      CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
      CREATE TABLE enrolled (sid INTEGER, cid TEXT, grade TEXT);
      INSERT INTO student VALUES
        (1, 'Asha', 'CSE'), (2, 'Bilal', 'ECE'), (3, 'Chitra', 'CSE'),
        (4, 'Dev', 'ME'), (5, 'Esha', 'CSE'), (6, 'Farhan', 'ECE');
      INSERT INTO course VALUES
        ('CS201', 'Databases', 'CSE', 4), ('CS202', 'Operating Systems', 'CSE', 4),
        ('CS301', 'Compilers', 'CSE', 4), ('MA101', 'Discrete Mathematics', 'MATH', 3),
        ('EC210', 'Signals and Systems', 'ECE', 3), ('HS101', 'Technical Writing', 'HSS', 2);
      INSERT INTO enrolled VALUES
        (1, 'CS201', 'A'), (1, 'CS202', 'B'), (1, 'MA101', 'A'), (2, 'EC210', 'A'),
        (2, 'MA101', 'B'), (3, 'CS201', 'B'), (3, 'MA101', 'A'), (4, 'MA101', 'C'),
        (5, 'CS201', 'A'), (5, 'CS202', 'A'), (5, 'MA101', 'B'), (5, 'HS101', 'A'),
        (NULL, 'CS301', 'B');
    expected:
      columns: [name]
      rows:
        - [Bilal]
        - [Dev]
        - [Farhan]
  - name: counts students with no enrolment, and lists a student once despite two other courses
    setup: |
      CREATE TABLE student (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL);
      CREATE TABLE course (cid TEXT PRIMARY KEY, title TEXT NOT NULL, dept TEXT NOT NULL, credits INTEGER NOT NULL);
      CREATE TABLE enrolled (sid INTEGER, cid TEXT, grade TEXT);
      INSERT INTO student VALUES (10, 'Ravi', 'ECE'), (11, 'Meena', 'CSE'), (12, 'Kiran', 'ME'), (13, 'Lata', 'ME');
      INSERT INTO course VALUES
        ('CS250', 'Computer Networks', 'CSE', 4), ('ME110', 'Mechanics', 'ME', 3), ('HS102', 'Ethics', 'HSS', 2);
      INSERT INTO enrolled VALUES (10, 'ME110', 'A'), (10, 'HS102', 'B'), (11, 'CS250', 'A'), (12, 'CS250', 'C');
    expected:
      columns: [name]
      rows:
        - [Ravi]
        - [Lata]
A hint

This is an antijoin: the students for whom no matching enrolment in a CSE course exists. Run the starter on the sample tests and look at the test with the broken row: NOT IN meets a NULL in its list, and then no comparison is ever true. NOT EXISTS asks only whether a matching row exists.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 Enrolled(sid, cid, grade) has 12,000 tuples and Student(sid, name, branch) has 1,500. sid is the key of Student, and every enrolment's sid is a non-null foreign key to Student. How many tuples does Enrolled ⋈ Student have?

    Type a number.

    Show the answer to question 1

    Answer: 12000 tuples

    Each enrolment's sid appears in Student (the foreign key), and only once (the key), so each of the 12,000 enrolments joins with exactly one student. The size of Student does not enter into it.

  2. Question 2 of 6 R(a, b) has 200 tuples and S(b, c) has 50, and b is a key of S. Nothing else is known. What is the largest possible size of R ⋈ S?

    Type a number.

    Show the answer to question 2

    Answer: 200 tuples

    Because b is a key of S, each tuple of R finds at most one partner, so the join has at most 200 tuples. Without a foreign key it can have fewer, down to 0 when no b of R appears in S.

  3. Question 3 of 6 R has 30 tuples and S has 40, and they share no attribute name. How many tuples does the natural join R ⋈ S have?

    Type a number.

    Show the answer to question 3

    Answer: 1200 tuples

    With no common attribute there is no equality to test, so every pair of tuples qualifies: the natural join is the product R × S, with 30 × 40 = 1,200 tuples.

  4. Question 4 of 6 R(a, b) has 4 tuples and S(b, c) has 3, and no value of b appears in both. How many tuples does the full outer join R ⟗ S have?

    Type a number.

    Show the answer to question 4

    Answer: 7 tuples

    The inner join is empty, so every tuple of both sides is unmatched and appears once, padded with NULL: 4 + 3 = 7.

  5. Question 5 of 6 Which query lists all six students, with NULL as the grade of those who did not take CS201?

    Choose one answer.

    Show the answer to question 5

    Answer: SELECT s.name, e.grade FROM student s LEFT JOIN enrolled e ON e.sid = s.sid AND e.cid = 'CS201'

    Only the condition in ON decides which enrolments may join before the padding happens. In WHERE it runs after the join, and e.cid = 'CS201' is not true for the padded rows, whose e.cid is NULL, so they are dropped: on_vs_where.sql shows 6 rows against 3. The inner joins drop the students without a CS201 enrolment anyway.

  6. Question 6 of 6 The subquery of WHERE sid NOT IN (SELECT sid FROM waitlist) returns the values 2 and NULL. Which students does the query return?

    Choose one answer.

    Show the answer to question 6

    Answer: None at all

    For a student with sid 4, NOT IN has to show 4 ≠ 2 and 4 ≠ NULL. The second comparison is unknown, so the whole condition is unknown, never true, and WHERE keeps the row only when its condition is true. NOT EXISTS returns five students on the same data, as semi_anti.sql shows.

Interview questions

What does a natural join return when the two relations share no attribute names, and when they share all of them? With no common attribute there is nothing to compare, so every pair of tuples qualifies and the natural join is the Cartesian product, with |R| × |S| tuples. When every attribute is common, two tuples join only if they agree on all of them, that is, if they are the same tuple, so the natural join is the intersection R ∩ S. SQL’s NATURAL JOIN behaves the same way, which is why a column added to both tables can change its result.

Key takeaways

  • A theta join is σ θ (R × S); an equi-join tests only equality; a natural join equates every common attribute name and keeps one copy of each.
  • A natural join with no common attribute is a product, and with every attribute in common it is an intersection.
  • Outer joins keep the tuples without a partner, padded with NULL; in SQL, a condition in ON runs before the padding and one in WHERE runs after it.
  • A semijoin keeps the tuples of R that have a partner in S, and an antijoin those that have none; write antijoins with NOT EXISTS, because NOT IN returns nothing once its subquery yields a NULL.
  • If the join attribute is a key of S, R ⋈ S has at most |R| tuples, and exactly |R| when it is also a non-null foreign key in R; without keys, anything from 0 to |R| × |S| is possible.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.