Skip to content
academia.sh

Lesson 09 / 18

Cross and Self Join

The Cartesian product an unconditioned join produces, the signs of an accidental product, connecting a table to itself under two aliases, and eliminating duplicate pairs.

Contents

The previous two lessons built a join on an equality condition: the foreign key of the left-hand row equal to the primary key of the right-hand row. The condition does not always have to take this shape — a join with no condition at all is defined too.

This lesson looks at two special cases. The first is the unconditioned join: the product in which every row matches every other row. The second is a table joined with itself: queries comparing two rows drawn from the same table. Neither is an exception to the rule; both are edge cases of the same rule.

The block below builds the schema and the sample data; every query in this lesson runs against the library.db file it creates.

rm -f library.db
sqlite3 library.db <<'SQL'
CREATE TABLE branch (branch_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL);
CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL,
                    publication_year INTEGER, branch_id INTEGER REFERENCES branch(branch_id));
CREATE TABLE member (member_id INTEGER PRIMARY KEY, first_name TEXT NOT NULL, last_name TEXT NOT NULL,
                  email TEXT, registered_at TEXT NOT NULL);
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL REFERENCES book(book_id),
                    member_id INTEGER NOT NULL REFERENCES member(member_id), pickup_date TEXT NOT NULL,
                    return_date TEXT);
INSERT INTO branch VALUES (1,'Central','Ankara'),(2,'Bahcelievler','Ankara'),
  (3,'Kadikoy','Istanbul'),(4,'Konak','Izmir');
INSERT INTO book VALUES (1,'Blindness','José Saramago',1995,1),(2,'The Disconnected','Oğuz Atay',1972,1),
  (3,'The Book of Sand','Jorge Luis Borges',1975,2),(4,'Yaban','Yakup Kadri',1932,2),
  (5,'Silent House','Orhan Pamuk',1983,3),(6,'Motherland Hotel','Yusuf Atılgan',NULL,3),
  (7,'Tehlikeli Oyunlar','Oğuz Atay',1973,NULL);
INSERT INTO member VALUES (1,'Alice','Kane','[email protected]','2023-02-14'),
  (2,'Ben','Ortiz','[email protected]','2023-05-30'),(3,'Clara','Diaz',NULL,'2024-01-09'),
  (4,'Derek','Voss','[email protected]','2024-03-22'),(5,'Grace','Kim',NULL,'2024-11-05'),
  (6,'Owen','Park','[email protected]','2025-01-18');
INSERT INTO loan VALUES (1,1,1,'2025-01-10','2025-01-24'),(2,2,1,'2025-02-02','2025-02-20'),
  (3,1,2,'2025-02-11',NULL),(4,3,3,'2025-03-01','2025-03-15'),(5,4,3,'2025-03-18','2025-04-02'),
  (6,1,4,'2025-04-05','2025-04-19'),(7,5,4,'2025-04-21',NULL),(8,2,5,'2025-05-02','2025-05-30'),
  (9,7,1,'2025-05-14','2025-05-28'),(10,3,5,'2025-06-03',NULL),(11,6,2,'2025-06-11','2025-06-25'),
  (12,4,4,'2025-06-20','2025-07-04');
SQL

The Cartesian Product

A cross join takes no condition at all: every row on the left is matched with every row on the right. The result’s row count is the product of the two tables’ row counts. Its counterpart in set theory is the Cartesian product.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT COUNT(*) AS book_rows FROM book;
SELECT COUNT(*) AS branch_rows FROM branch;
SELECT COUNT(*) AS product FROM book CROSS JOIN branch;
SQL
book_rows
---------
7        
branch_rows
-----------
4          
product
-------
28     

Seven times four, twenty-eight. The product looks harmless in a small example; across two tables with ten thousand rows each, it means a hundred million rows. This is the most expensive result a poorly written query can produce, and it is one of the first symptoms looked for when reading a query plan in the Advanced SQL course.

The result can be kept to a visible size by filtering the right-hand side.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT br.name AS branch, b.title
FROM branch AS br
CROSS JOIN book AS b
WHERE b.author = 'Oğuz Atay'
ORDER BY br.branch_id, b.book_id;
SQL
branch        title            
------------  -----------------
Central       The Disconnected 
Central       Tehlikeli Oyunlar
Bahcelievler  The Disconnected 
Bahcelievler  Tehlikeli Oyunlar
Kadikoy       The Disconnected 
Kadikoy       Tehlikeli Oyunlar
Konak         The Disconnected 
Konak         Tehlikeli Oyunlar

Four branches, two books, eight rows. No row is claiming “this book is at this branch” — a cross join does not produce a fact, it produces a list of possibilities. That is also where it becomes useful: building every possible combination of every branch with every book, then overlaying the ones that actually exist with an outer join, gives a complete report grid in which the empty cells are visible too.

The Accidental Product

A cross join is rarely written on purpose; it appears when a join condition is forgotten. In the old comma-separated syntax, where the condition is left to the WHERE clause, this slip is especially easy to make.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT COUNT(*) AS no_condition FROM book, branch;
SQL
no_condition
------------
28          

The result matches the explicitly written cross join exactly: twenty-eight. The query raised no error. The previous lesson’s conditioned join on these same two tables returned six rows.

The symptom is recognizable: far more rows than expected, repeated values, and totals that multiply. The remedy is a matter of syntax too — the JOIN … ON form keeps the condition next to the join, so forgetting it stands out. Writing CROSS JOIN also documents that the product was intentional; a reader of the query can see that the product was the plan.

Self Join

A table can be joined with itself. Doing so requires giving the table two different aliases; from the engine’s point of view, this is exactly like two separate tables. The name for the pattern is self join, and it is not a separate join type — it is an instance of an inner or outer join in which the same table appears on both sides.

The query finding pairs of books by the same author is written first in its unconditioned form.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT a.title AS book_a, b.title AS book_b
FROM book AS a
JOIN book AS b ON a.author = b.author
ORDER BY a.book_id, b.book_id;
SQL
book_a             book_b           
-----------------  -----------------
Blindness          Blindness        
The Disconnected   The Disconnected 
The Disconnected   Tehlikeli Oyunlar
The Book of Sand   The Book of Sand 
Yaban              Yaban            
Silent House       Silent House     
Motherland Hotel   Motherland Hotel 
Tehlikeli Oyunlar  The Disconnected 
Tehlikeli Oyunlar  Tehlikeli Oyunlar

Only two of the nine rows are meaningful, and those two are the same pair seen from both directions. The two typical flaws of a self join show up here. First, every row matches itself — because its author equals itself. Second, the real pair appears twice, with its direction reversed.

Both are removed by a single condition: tying the match to the order of the primary key.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT a.author, a.title AS first, b.title AS second
FROM book AS a
JOIN book AS b ON a.author = b.author AND a.book_id < b.book_id
ORDER BY a.book_id;
SQL
author     first             second           
---------  ----------------  -----------------
Oğuz Atay  The Disconnected  Tehlikeli Oyunlar

The condition a.book_id < b.book_id does two jobs at once. Because equality is excluded, a row can no longer match itself; because the ordering runs one way, each pair appears only once. Writing the strict inequality as <> instead would solve only the first problem, leaving the mirrored duplicates in place.

The same pattern applies to another question: pairs of branches in the same city.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT a.city, a.name AS branch_1, b.name AS branch_2
FROM branch AS a
JOIN branch AS b ON a.city = b.city AND a.branch_id < b.branch_id;
SQL
city    branch_1  branch_2    
------  --------  ------------
Ankara  Central   Bahcelievler

Chaining a Self Join

A self join can be combined with other joins. Finding pairs of members who borrowed the same book requires joining the loan table with itself, and then joining that result with the member and book tables.

sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT b.title, m1.first_name AS member_1, m2.first_name AS member_2
FROM loan AS l1
JOIN loan AS l2 ON l1.book_id = l2.book_id AND l1.member_id < l2.member_id
JOIN member AS m1  ON m1.member_id = l1.member_id
JOIN member AS m2  ON m2.member_id = l2.member_id
JOIN book AS b   ON b.book_id = l1.book_id
ORDER BY b.book_id, m1.member_id, m2.member_id;
SQL
title             member_1  member_2
----------------  --------  --------
Blindness         Alice     Ben     
Blindness         Alice     Derek   
Blindness         Ben       Derek   
The Disconnected  Alice     Grace   
The Book of Sand  Clara     Grace   
Yaban             Clara     Derek   

Five table references appear, but the table count is three: the loan table appears twice, the member table appears twice. Aliases are not optional here — nothing else could say which member_id column is meant.

Blindness produced three rows because three separate members borrowed it: the number of pairs chosen from three items is three. A self join’s row count grows with the square of the group size; in large groups this can become as expensive as a cross join.

Summary

  • A cross join is unconditioned; its row count is the product of the two tables’ row counts.
  • The product is not a list of facts but a list of possibilities; it is useful for building a report grid.
  • An accidental cross join forms without error when a join condition is forgotten; its symptom is far more rows than expected and multiplying totals.
  • A self join is not a separate type; it is referencing the same table under two aliases, and the alias is required.
  • An unconditioned self join matches every row with itself and every pair in both directions; a strict inequality on the primary key removes both at once.
  • The cost of a pair-producing join grows with the square of the group size.

Next Step

Every query so far has stayed at row level: each result row corresponded to some combination of source rows. Questions like “how many books,” “average loan duration,” and “earliest publication year” summarize not rows but sets of rows. The next lesson builds the aggregate functions that reduce a row set to a single value, and measures why those functions do not count null values.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close