These are the two JOINs that most bewilder beginners, and for opposite reasons. The SELF JOIN looks impossible —how do you join a table to itself without going round in a loop?— and turns out to be the only way to walk the reflexive relationships you've been seeing since 01-05: employees.manager_id and customers.referred_by_id. The CROSS JOIN looks like a mistake —it's the cartesian product we presented in 03-01 as an accident— and turns out to be a deliberate tool for generating complete combinations.
Neither of them is a new "type" of matching. The SELF JOIN is a technique that can use any type (INNER, LEFT...); the CROSS JOIN is the degenerate case of a JOIN with no condition.
Contents
SELF JOIN: joining a table to itself- The
employeeshierarchy - The
customersreferral network - Non-hierarchical
SELF JOIN: pairs within the same group - Employees in the same city
- Hierarchies of arbitrary depth
CROSS JOIN: the deliberate cartesian product- When it's useful and when it's an accident
CROSS JOINwithgenerate_seriesfor calendars- Common Mistakes and Tips
- Exercises
- Conclusion
SELF JOIN: joining a table to itself
SELF JOIN: joining a table to itselfA SELF JOIN has no syntax of its own. It's an ordinary JOIN in which both tables are the same one:
No infinite loops: the engine treats each appearance of the table as an independent relation. It's exactly 03-01's cartesian product, with employees on both sides, filtered by the ON condition.
flowchart LR
A["employees<br/>as 'e'<br/>8 rows"] --> C["cartesian<br/>8 × 8 = 64 rows"]
B["employees<br/>as 'manager'<br/>8 rows"] --> C
C --> D["ON filter<br/>e.manager_id = manager.id"]
D --> E["7 rows<br/>(Rosa has no manager)"]
Why aliases stop being optional
In 03-01 we said table aliases were a convenience. In a SELF JOIN they're essential, and for a physical reason: without them, the name employees would designate two different things at once.
-- ⚠️ INCORRECT
SELECT name, manager_id
FROM employees
JOIN employees ON employees.manager_id = employees.id;PostgreSQL doesn't even try to guess: it refuses. With different aliases, each appearance has its own identity and everything works.
Course convention: in a
SELF JOIN, aliases aren't abbreviated by initial but named after the role each copy plays.employees AS eandemployees AS manager;customers AS candcustomers AS referrer;products AS p1andproducts AS p2when the two roles are symmetric. An alias likee1/e2is acceptable in symmetric pairs, bute/manageris always more readable thane1/e2when the roles differ.
- The
employees hierarchy
employees hierarchyemployees.manager_id is a foreign key pointing at employees.id: the reflexive 1:N relationship you drew in the ER diagram in 01-06. Each employee has at most one manager, and a manager can have several reports.
2.1. With INNER JOIN: the general manager gets lost
SELECT e.id,
e.name || ' ' || e.last_name AS employee,
e.job_title,
manager.name || ' ' || manager.last_name AS manager
FROM employees AS e
JOIN employees AS manager ON e.manager_id = manager.id
ORDER BY e.id;| id | employee | job_title | manager |
|---|---|---|---|
| 2 | Andrés Company Talens | Sales manager | Rosa Alcázar Vives |
| 3 | Beatriz Nadal Ripoll | Logistics manager | Rosa Alcázar Vives |
| 4 | Óscar Peris Blasco | Sales rep | Andrés Company Talens |
| 5 | Laia Puig Sanchis | Sales rep | Andrés Company Talens |
| 6 | Marc Estévez Roig | Customer support | Andrés Company Talens |
| 7 | Irene Salvador Mira | Warehouse operator | Beatriz Nadal Ripoll |
| 8 | Daniel Vercher Lluch | Data analyst | Rosa Alcázar Vives |
7 rows out of 8. Rosa Alcázar Vives, the general manager, is missing, because her manager_id is NULL and matches nobody. It's exactly 03-02's mechanism: a null FK never finds a partner.
And it's a dangerous result, because it looks complete. An org chart that leaves out the general manager is a wrong org chart.
2.2. With LEFT JOIN: the complete org chart
-- ✅ CORRECT
SELECT e.id,
e.name || ' ' || e.last_name AS employee,
e.job_title,
manager.name || ' ' || manager.last_name AS manager,
manager.job_title AS manager_job_title
FROM employees AS e
LEFT JOIN employees AS manager ON e.manager_id = manager.id
ORDER BY e.id;| id | employee | job_title | manager | manager_job_title |
|---|---|---|---|---|
| 1 | Rosa Alcázar Vives | General manager | (null) | (null) |
| 2 | Andrés Company Talens | Sales manager | Rosa Alcázar Vives | General manager |
| 3 | Beatriz Nadal Ripoll | Logistics manager | Rosa Alcázar Vives | General manager |
| 4 | Óscar Peris Blasco | Sales rep | Andrés Company Talens | Sales manager |
| 5 | Laia Puig Sanchis | Sales rep | Andrés Company Talens | Sales manager |
| 6 | Marc Estévez Roig | Customer support | Andrés Company Talens | Sales manager |
| 7 | Irene Salvador Mira | Warehouse operator | Beatriz Nadal Ripoll | Logistics manager |
| 8 | Daniel Vercher Lluch | Data analyst | Rosa Alcázar Vives | General manager |
8 rows: the complete team. Rosa's NULL means "root of the hierarchy", and in a report you'd present it as "—" or "Management" with COALESCE (06-04).
Rule: in a hierarchical
SELF JOIN, the root of the tree always has its FK atNULL. If you want it to appear, theJOINhas to be aLEFT. It's the most frequent mistake when building org charts, category trees or folder structures.
2.3. Turning it around: each manager with their reports
Reversing the direction of the condition gives you the inverse relationship. It's enough to read the ON backwards:
SELECT manager.name || ' ' || manager.last_name AS manager,
sub.name || ' ' || sub.last_name AS report,
sub.job_title
FROM employees AS manager
JOIN employees AS sub ON sub.manager_id = manager.id
ORDER BY manager.id, sub.id;It returns the same 7 pairings, presented from the manager's point of view. Notice that the ON condition is identical; the only things that change are which alias is called what and which columns are projected. It's the same lesson from 03-04 about LEFT and RIGHT: the matching doesn't change, the reading does.
- The
customers referral network
customers referral networkcustomers.referred_by_id works the same way, but with a different business nuance: here the NULL doesn't mean "root of a hierarchy" but "arrived on their own".
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
c.city,
referrer.name || ' ' || referrer.last_name AS referred_by
FROM customers AS c
LEFT JOIN customers AS referrer ON c.referred_by_id = referrer.id
ORDER BY c.id;| id | customer | city | referred_by |
|---|---|---|---|
| 1 | Lucía Martínez Soler | Valencia | (null) |
| 2 | Carlos Ferrer Ibáñez | Valencia | Lucía Martínez Soler |
| 3 | Marta Sanchis Gil | Castellón | Lucía Martínez Soler |
| 4 | Javier Ortega Ruiz | Madrid | (null) |
| 5 | Ana Belmonte Roca | Barcelona | Carlos Ferrer Ibáñez |
| 6 | Pau Llorens Vidal | Valencia | (null) |
| 7 | Sofia Moreira Costa | Lisbon | (null) |
| 8 | Tiago Almeida Nunes | Porto | Sofia Moreira Costa |
| 9 | Camille Dubois | Lyon | (null) |
| 10 | Julien Moreau | Paris | Camille Dubois |
| 11 | Elena Navarro Puig | Alicante | Pau Llorens Vidal |
| 12 | Diego Ramos Herrera | Seville | (null) |
| 13 | Núria Bosch Ferrer | Barcelona | Ana Belmonte Roca |
| 14 | Hugo Iglesias Pardo | Zaragoza | (null) |
| 15 | Inés Carrasco Vega | Valencia | Lucía Martínez Soler |
15 rows: 8 referred customers and 7 with no referrer, exactly the counts 01-06 announced.
The network these data draw:
flowchart TD
L["Lucía (1)"] --> C2["Carlos (2)"]
L --> M["Marta (3)"]
L --> I["Inés (15)"]
C2 --> A["Ana (5)"]
A --> N["Núria (13)"]
S["Sofia (7)"] --> T["Tiago (8)"]
CD["Camille (9)"] --> J["Julien (10)"]
P["Pau (6)"] --> E["Elena (11)"]
JA["Javier (4)"]
D["Diego (12)"]
H["Hugo (14)"]
Who brought whom: the referrer's point of view
Marketing wants to reward the customers who have brought in the most people. The query starts from the referrer:
SELECT referrer.name || ' ' || referrer.last_name AS referrer,
c.id AS customer_id,
c.name || ' ' || c.last_name AS referred,
c.signup_date
FROM customers AS referrer
JOIN customers AS c ON c.referred_by_id = referrer.id
WHERE referrer.id = 1
ORDER BY c.id;| referrer | customer_id | referred | signup_date |
|---|---|---|---|
| Lucía Martínez Soler | 2 | Carlos Ferrer Ibáñez | 2025-01-22 |
| Lucía Martínez Soler | 3 | Marta Sanchis Gil | 2025-02-03 |
| Lucía Martínez Soler | 15 | Inés Carrasco Vega | 2026-01-08 |
Three customers brought in by Lucía, GreenStore's first customer. In module 4 we'll count these referrals per person with GROUP BY to find out who leads the programme; here we stay at the detail level.
Notice a subtle writing detail: even though the alias referrer is written first in the FROM, it's still the "parent" table of the relationship. Which one comes first is a readability decision, not a meaning one: what sets the direction is the condition ON c.referred_by_id = referrer.id.
- Non-hierarchical
SELF JOIN: pairs within the same group
SELF JOIN: pairs within the same groupNot every SELF JOIN walks a declared reflexive relationship. Another very frequent use is pairing rows that share an attribute: products in the same category, employees in the same city, orders from the same day.
The marketing team wants to design bundles of two products from the same category. First attempt:
This returns 78 rows, and it has two serious problems:
| Problem | Example |
|---|---|
| It pairs each product with itself | (Face cream, Face cream): a "bundle" of one duplicated product |
| It returns each pair twice | (Cream, Shampoo) and (Shampoo, Cream) are the same offer |
The fix fits in three characters: p1.id < p2.id.
-- ✅ CORRECT
SELECT cat.name AS category,
p1.name AS product_a,
p1.price AS price_a,
p2.name AS product_b,
p2.price AS price_b
FROM products AS p1
JOIN products AS p2 ON p1.category_id = p2.category_id
AND p1.id < p2.id
JOIN categories AS cat ON p1.category_id = cat.id
WHERE p1.category_id = 2
ORDER BY p1.id, p2.id;| category | product_a | price_a | product_b | price_b |
|---|---|---|---|---|
| Natural cosmetics | Aloe vera face cream 50 ml | 18.90 | Rosemary solid shampoo 80 g | 8.40 |
| Natural cosmetics | Aloe vera face cream 50 ml | 18.90 | Almond body oil 200 ml | 14.25 |
| Natural cosmetics | Aloe vera face cream 50 ml | 18.90 | Calendula lip balm 15 ml | 4.60 |
| Natural cosmetics | Rosemary solid shampoo 80 g | 8.40 | Almond body oil 200 ml | 14.25 |
| Natural cosmetics | Rosemary solid shampoo 80 g | 8.40 | Calendula lip balm 15 ml | 4.60 |
| Natural cosmetics | Almond body oil 200 ml | 14.25 | Calendula lip balm 15 ml | 4.60 |
6 rows: the 6 possible pairs among the 4 Natural cosmetics products. Without the trimming WHERE, the query returns 29 pairs across the whole catalogue.
Why p1.id < p2.id works
This trick deserves a whole paragraph, because it's the canonical pattern and gets reused in a thousand places.
For any two products A and B from the same category, the cartesian product generates four combinations:
| Combination | Does it satisfy p1.id < p2.id? |
What it is |
|---|---|---|
| (A, A) | ❌ 5 < 5 is false |
A product with itself |
| (B, B) | ❌ | A product with itself |
(A, B) with id(A) < id(B) |
✅ | The pair, exactly once |
| (B, A) | ❌ id(B) < id(A) is false |
The same pair, duplicated |
The condition does two jobs at once with a single operator:
<instead of<=removes the reflexive pairs (A, A), because noidis less than itself.<instead of<>removes the duplicate (B, A), because of the two possible orderings only one satisfies the strict inequality.
The arithmetic confirms the effect: with n products in a category, with no condition there are n² combinations; with <> there are n² − n; with < there are exactly n(n−1)/2, which is the combinatorial number of pairs.
| Category | Products | No condition (n²) |
With <> |
With < |
|---|---|---|---|---|
| Food | 5 | 25 | 20 | 10 |
| Natural cosmetics | 4 | 16 | 12 | 6 |
| Sustainable home | 4 | 16 | 12 | 6 |
| Drinks | 4 | 16 | 12 | 6 |
| Personal hygiene | 2 | 4 | 2 | 1 |
| Supplements | 1 | 1 | 0 | 0 |
| Total | 20 | 78 | 58 | 29 |
Note: this is a
JOINwith an inequality condition, what in 03-01 we called a non-equi join. The equalityp1.category_id = p2.category_iddefines the group; the inequalityp1.id < p2.idselects one of the two orderings. It's usual for a non-equi join to accompany an equality one, not to replace it.
- Employees in the same city
The same pattern, applied to employees. HR wants to organise team lunches by office and needs the pairs of colleagues who work in the same city:
SELECT e1.city,
e1.name AS employee_a,
e1.job_title AS job_title_a,
e2.name AS employee_b,
e2.job_title AS job_title_b
FROM employees AS e1
JOIN employees AS e2 ON e1.city = e2.city
AND e1.id < e2.id
ORDER BY e1.id, e2.id
LIMIT 10;| city | employee_a | job_title_a | employee_b | job_title_b |
|---|---|---|---|---|
| Valencia | Rosa | General manager | Andrés | Sales manager |
| Valencia | Rosa | General manager | Beatriz | Logistics manager |
| Valencia | Rosa | General manager | Óscar | Sales rep |
| Valencia | Rosa | General manager | Marc | Customer support |
| Valencia | Rosa | General manager | Irene | Warehouse operator |
| Valencia | Rosa | General manager | Daniel | Data analyst |
| Valencia | Andrés | Sales manager | Beatriz | Logistics manager |
| Valencia | Andrés | Sales manager | Óscar | Sales rep |
| Valencia | Andrés | Sales manager | Marc | Customer support |
| Valencia | Andrés | Sales manager | Irene | Warehouse operator |
(First 10 of 21 rows.)
21 pairs, all of them from Valencia: seven of the eight employees work there, and 7 × 6 / 2 = 21. Laia Puig Sanchis doesn't appear in a single row, because she's the only one working in Castellón and has nobody to pair with.
That detail matters: a SELF JOIN of this kind automatically excludes the elements that are unique within their group. If you wanted a listing where Laia also appeared (with NULL as her colleague), you'd need a LEFT JOIN with the same condition in the ON.
It's also worth noting that here city allows NULL. If two employees had the city unfilled, NULL = NULL isn't true and they wouldn't be paired, which in this case is the right behaviour: we don't know whether they work together.
- Hierarchies of arbitrary depth
A SELF JOIN walks exactly one level of the hierarchy. To go up two levels you need two chained SELF JOINs:
SELECT e.id,
e.name AS employee,
e.job_title,
manager.name AS manager,
top_manager.name AS managers_manager
FROM employees AS e
LEFT JOIN employees AS manager ON e.manager_id = manager.id
LEFT JOIN employees AS top_manager ON manager.manager_id = top_manager.id
ORDER BY e.id;| id | employee | job_title | manager | managers_manager |
|---|---|---|---|---|
| 1 | Rosa | General manager | (null) | (null) |
| 2 | Andrés | Sales manager | Rosa | (null) |
| 3 | Beatriz | Logistics manager | Rosa | (null) |
| 4 | Óscar | Sales rep | Andrés | Rosa |
| 5 | Laia | Sales rep | Andrés | Rosa |
| 6 | Marc | Customer support | Andrés | Rosa |
| 7 | Irene | Warehouse operator | Beatriz | Rosa |
| 8 | Daniel | Data analyst | Rosa | (null) |
It works, and in GreenStore it's enough because the org chart has only three levels. But notice the limitation: the number of levels is hard-coded into the query. If the company grows to five levels, you have to rewrite it adding two more LEFT JOINs; if an employee sits seven levels below management, this query will never reach them.
For hierarchies of unknown depth you need another tool: recursive CTEs (
WITH RECURSIVE), which are studied in lesson 10-02. With them you walk a tree of any depth in a single query, and you can compute each employee's level or the complete chain of command. The same goes for the referral network: "every customer descending from Lucía, directly or indirectly" is a recursive query, not aSELF JOIN.
CROSS JOIN: the deliberate cartesian product
CROSS JOIN: the deliberate cartesian productThe CROSS JOIN combines every row of one table with every row of the other, with no condition at all. It's step 1 of 03-01's mental model, without step 2.
It returns 6 × 20 = 120 rows. No condition, no ON: CROSS JOIN doesn't accept ON, and if you write one you get a syntax error.
The size grows as the product of the cardinalities, which on real tables is explosive:
| Table A | Table B | Resulting rows |
|---|---|---|
categories (6) |
suppliers (5) |
30 |
categories (6) |
12 months | 72 |
products (20) |
customers (15) |
300 |
customers (15) |
products (20) × 12 months |
3,600 |
order_lines (47) |
orders (20) |
940 |
| 10,000 | 10,000 | 100,000,000 |
| 1,000,000 | 1,000,000 | 1,000,000,000,000 |
That last row —a trillion rows— is the reason an accidental CROSS JOIN can run a server out of memory. The rule is simple: a CROSS JOIN is only acceptable when at least one of the two tables is small and of known size.
The two ways of writing it
-- Explicit form: recommended
FROM categories AS cat CROSS JOIN suppliers AS s
-- Comma form: equivalent, but indistinguishable from an oversight
FROM categories AS cat, suppliers AS sThey're identical to the engine. The first is a statement of intent; the second is exactly what shows up when somebody forgets the matching condition with 03-01's old syntax.
Course convention: if you want a cartesian product, write
CROSS JOINin capitals and with a comment explaining why. It's the difference between "this is intended" and "this is an oversight".
- When it's useful and when it's an accident
| Situation | Deliberate? | Example |
|---|---|---|
| Generating every combination for a report so there are no gaps | ✅ Yes | Category × month, so a month with no sales appears with 0 |
| Building a matrix of variants | ✅ Yes | Sizes × colours of a clothing product |
| Multiplying a row of parameters against a table | ✅ Yes | Applying three VAT scenarios to the whole catalogue |
| Generating test data in volume | ✅ Yes | Crossing two numeric series to create a million rows |
A JOIN that forgot its ON |
❌ No | 03-01's case: 20 × 15 = 300 rows of rubbish |
A table added to the FROM without relating it |
❌ No | Adding suppliers to a query and forgetting to join it |
The useful case par excellence: reports with no gaps
This is why the CROSS JOIN exists in an analyst's daily life.
Picture the report "sales by category and month of 2025". If you build it from the real sales alone, the months with no sales simply don't appear: a category that sold nothing in August won't have an August row, and the chart will jump from July to September as if August didn't exist.
The professional solution consists of generating the complete skeleton first —every category × month combination— and then joining the sales onto it with a LEFT JOIN:
flowchart LR
A["categories<br/>6 rows"] --> C["CROSS JOIN<br/>72 combinations"]
B["12 months<br/>generate_series"] --> C
C --> D["LEFT JOIN with the real sales"]
D --> E["report with no gaps:<br/>the months with no sales<br/>appear with NULL"]
The skeleton:
SELECT cat.id AS category_id,
cat.name AS category,
m.month
FROM categories AS cat
CROSS JOIN generate_series(1, 12) AS m(month)
ORDER BY cat.id, m.month;| category_id | category | month |
|---|---|---|
| 1 | Food | 1 |
| 1 | Food | 2 |
| 1 | Food | 3 |
| 1 | Food | 4 |
| 1 | Food | 5 |
| 1 | Food | 6 |
| 1 | Food | 7 |
| 1 | Food | 8 |
(First 8 of 72 rows.)
72 rows = 6 categories × 12 months. No combination is missing, and on that basis the report balances even if a category sells nothing all year. Adding up the sales is module 4's business; the skeleton belongs here.
The matrix of variants
The other classic case doesn't even need tables: it's built from literal lists using VALUES (which you'll study in depth in 05-02).
SELECT size.s AS size,
color.c AS color
FROM (VALUES ('S'), ('M'), ('L'), ('XL')) AS size(s)
CROSS JOIN (VALUES ('white'), ('black'), ('green')) AS color(c)
ORDER BY size.s, color.c;It returns 12 rows: the 4 sizes × 3 colours of a clothing catalogue. It's the list of references you'd have to create. GreenStore doesn't sell clothes, but the pattern shows up in any shop with product variants.
CROSS JOIN with generate_series for calendars
CROSS JOIN with generate_series for calendarsgenerate_series is a PostgreSQL row-generating function. It produces a series of values and is used in the FROM as if it were a table:
| generate_series |
|---|
| 1 |
| 2 |
| 3 |
| 4 |
| 5 |
It's with dates that it's most useful, because it accepts an interval as its step:
SELECT m.month::date
FROM generate_series(DATE '2025-01-01', DATE '2025-12-01', INTERVAL '1 month') AS m(month);| month |
|---|
| 2025-01-01 |
| 2025-02-01 |
| 2025-03-01 |
| 2025-04-01 |
| 2025-05-01 |
| 2025-06-01 |
| 2025-07-01 |
| 2025-08-01 |
| 2025-09-01 |
| 2025-10-01 |
| 2025-11-01 |
| 2025-12-01 |
And crossed with categories it generates the complete calendar for the previous report, now with real dates:
SELECT cat.name AS category,
m.month::date AS month
FROM categories AS cat
CROSS JOIN generate_series(DATE '2025-01-01', DATE '2025-12-01', INTERVAL '1 month') AS m(month)
ORDER BY cat.id, m.month;72 rows, ready to receive the sales with a LEFT JOIN.
Dialect note:
generate_seriesis PostgreSQL-specific. The equivalents in other engines:
Engine How to generate a series PostgreSQL generate_series(start, stop [, step])SQL Server GENERATE_SERIES(since 2022); before that, a recursive CTE or a calendar tableOracle CONNECT BY LEVEL <= nMySQL 8+ A recursive CTE, WITH RECURSIVESQLite A recursive CTE, or the generate_seriesextensionThe portable alternative on any engine is to maintain a permanent calendar table with one row per day or per month. It's what most data warehouses do, and it avoids depending on the dialect.
LATERAL, in passing
There's an advanced variant of the CROSS JOIN in which the right-hand table can refer to columns from the left:
It's called a CROSS JOIN LATERAL (or LEFT JOIN LATERAL) and it serves, for instance, for "the three most recent reviews of each product". It needs subqueries, which are module 7, so here we only mention it so you recognise the word if you see it. LATERAL is standard SQL and is available in PostgreSQL, Oracle and SQL Server (where it's called CROSS APPLY / OUTER APPLY).
Common Mistakes and Tips
- Forgetting the aliases in a
SELF JOIN.ERROR: table name "employees" specified more than once. Each copy needs its own name. - Using an
INNER JOINon a hierarchy. You lose the root of the tree: Rosa Alcázar Vives disappears from the org chart because hermanager_idisNULL. Use aLEFT JOIN. - Forgetting the
p1.id < p2.idcondition when generating pairs. You get every element paired with itself and every pair duplicated: 78 rows instead of 29. - Using
<>instead of<. It removes the reflexive pairs but not the duplicates: 58 rows instead of 29. - Believing a
SELF JOINwalks the whole hierarchy. It walks exactly one level. Two levels, twoJOINs. Unknown depth,WITH RECURSIVE(10-02). - Matching on a nullable column in a group
SELF JOIN. Two employees withcityatNULLdon't pair up, becauseNULL = NULLisn't true. - Writing a
CROSS JOINwith the comma syntax. It's correct, but indistinguishable from an oversight. Write an explicitCROSS JOIN. - Trying to put an
ONon aCROSS JOIN. It doesn't accept one: if you need a condition, it isn't aCROSS JOIN, it's anINNER JOIN. - Crossing two large tables "to see what comes out". Check the product of their counts first. 10,000 × 10,000 is a hundred million rows.
- Tip: draw the tree before writing the
SELF JOIN. Knowing who's the parent and who's the child stops you inverting theONcondition, which is the most common mistake and the hardest to spot. - Tip: count the expected pairs before running it.
n(n−1)/2for the pairs of a group ofn. If the result doesn't match, you know immediately that the condition is missing or superfluous. - Tip: for reports by period, always build the skeleton first.
CROSS JOINof the dimensions +LEFT JOINof the facts. It's the standard way to have no gaps, and you'll use it as soon as you start aggregating in module 4.
Exercises
Exercise 1
Marketing wants a referral programme sheet showing, for each customer: their full name, who referred them (or NULL if they arrived on their own) and who referred their referrer (the "grandparent" of the network).
- Write the query using two chained
SELF JOINs overcustomers. - Restrict it to the customers who do have a referrer and comment on the result.
- Why can't this query answer "every customer descending from Lucía, at any depth"?
Exercise 2
Purchasing wants to launch bundles of two products from the same category whose combined price doesn't exceed €10, using active products only. Write the query that returns the category, the two products with their prices and the bundle price, sorted by bundle price.
Explain the role each of the ON and WHERE conditions plays.
Exercise 3
Answer with reasoning, without running anything:
- How many rows does
SELECT * FROM employees AS e1 CROSS JOIN employees AS e2;return? - And
SELECT * FROM employees AS e1 JOIN employees AS e2 ON e1.id <> e2.id;? - And
SELECT * FROM employees AS e1 JOIN employees AS e2 ON e1.id < e2.id;? - Write the skeleton of a supplier × quarter of 2025 report using
CROSS JOINandgenerate_series. How many rows does it have?
Solutions
Solution 1
1. With two chained SELF JOINs:
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
referrer.name AS referrer,
referrer2.name AS referrers_referrer
FROM customers AS c
LEFT JOIN customers AS referrer ON c.referred_by_id = referrer.id
LEFT JOIN customers AS referrer2 ON referrer.referred_by_id = referrer2.id
ORDER BY c.id;It returns the 15 rows of customers, with two columns that fall to NULL as the chain runs out.
2. Only the customers with a referrer (it's enough to change the first LEFT JOIN into an INNER JOIN; the second must remain a LEFT):
SELECT c.name || ' ' || c.last_name AS customer,
referrer.name AS referrer,
referrer2.name AS referrers_referrer
FROM customers AS c
INNER JOIN customers AS referrer ON c.referred_by_id = referrer.id
LEFT JOIN customers AS referrer2 ON referrer.referred_by_id = referrer2.id
ORDER BY c.id;| customer | referrer | referrers_referrer |
|---|---|---|
| Carlos Ferrer Ibáñez | Lucía | (null) |
| Marta Sanchis Gil | Lucía | (null) |
| Ana Belmonte Roca | Carlos | Lucía |
| Tiago Almeida Nunes | Sofia | (null) |
| Julien Moreau | Camille | (null) |
| Elena Navarro Puig | Pau | (null) |
| Núria Bosch Ferrer | Ana | Carlos |
| Inés Carrasco Vega | Lucía | (null) |
8 rows. Only two customers have a "grandparent" in the network: Ana (brought in by Carlos, who in turn came from Lucía) and Núria (brought in by Ana, who came from Carlos). The rest descend directly from somebody who arrived on their own.
Notice that the chain Lucía → Carlos → Ana → Núria has three hops, and this query only gets to see two: Núria's row shows Carlos as the grandparent, but Lucía —the great-grandparent— no longer fits.
3. Why it's no good for "every descendant of Lucía": because the number of levels is written into the query. Each extra level demands another LEFT JOIN, and to answer that question you'd have to know the network's maximum depth in advance. With a chain of seven referrals, you'd need seven JOINs. The right tool is a recursive CTE (WITH RECURSIVE), which walks the tree until it's exhausted in a single query: lesson 10-02.
Solution 2
SELECT cat.name AS category,
p1.name AS product_a,
p1.price AS price_a,
p2.name AS product_b,
p2.price AS price_b,
ROUND(p1.price + p2.price, 2) AS bundle_price
FROM products AS p1
JOIN products AS p2 ON p1.category_id = p2.category_id
AND p1.id < p2.id
JOIN categories AS cat ON p1.category_id = cat.id
WHERE p1.active
AND p2.active
AND p1.price + p2.price <= 10
ORDER BY p1.price + p2.price, p1.id, p2.id;| category | product_a | price_a | product_b | price_b | bundle_price |
|---|---|---|---|---|---|
| Food | Spelt pasta 500 g | 2.80 | Organic crushed tomato 400 g | 1.95 | 4.75 |
| Food | Organic brown rice 1 kg | 3.90 | Organic crushed tomato 400 g | 1.95 | 5.85 |
| Food | Organic brown rice 1 kg | 3.90 | Spelt pasta 500 g | 2.80 | 6.70 |
| Drinks | Organic chamomile tea 20 bags | 3.25 | Ginger kombucha 750 ml | 4.95 | 8.20 |
| Drinks | Organic chamomile tea 20 bags | 3.25 | Cold-pressed orange juice 1 L | 5.40 | 8.65 |
5 possible bundles, all from Food and Drinks: they're the two categories with cheap products. In Natural cosmetics, the cheapest bundle would be the lip balm (€4.60) with the shampoo (€8.40), which already comes to €13.
The role of each condition:
| Condition | Where | Role |
|---|---|---|
p1.category_id = p2.category_id |
ON |
Matching: defines the group within which pairs are formed |
p1.id < p2.id |
ON |
Matching: avoids the reflexive pair and the inverted duplicate |
p1.category_id = cat.id |
ON |
Matching: brings in the category's name |
p1.active AND p2.active |
WHERE |
Filtering: discards discontinued products. It excludes the Spirulina capsules, the only product with active = false |
p1.price + p2.price <= 10 |
WHERE |
Filtering: the business rule on the bundle price |
The matching conditions go in the ON and the filtering ones in the WHERE, following 03-01's convention. Since every JOIN is an INNER, here it would be equivalent to put them in either place (03-02), but the separation keeps the query readable.
Solution 3
1. A CROSS JOIN of employees with itself: 8 × 8 = 64 rows. Every combination, including the eight pairs of each employee with themselves.
2. With ON e1.id <> e2.id: 56 rows. The 8 reflexive pairs are removed (64 − 8), but each pair still appears twice, in both orders.
3. With ON e1.id < e2.id: 28 rows. That's 8 × 7 / 2, the number of distinct pairs from 8 elements. Each pair exactly once and none with itself.
The progression 64 → 56 → 28 sums up the whole of section 4.
4. Supplier × quarter of 2025 skeleton:
SELECT s.id AS supplier_id,
s.name AS supplier,
q.quarter::date AS quarter_start
FROM suppliers AS s
CROSS JOIN generate_series(DATE '2025-01-01', DATE '2025-10-01', INTERVAL '3 months') AS q(quarter)
ORDER BY s.id, q.quarter;20 rows = 5 suppliers × 4 quarters. The generated quarters start on 1 January, 1 April, 1 July and 1 October 2025; the upper bound is 2025-10-01 because generate_series includes the final endpoint, and putting 2025-12-01 would produce an unwanted fifth value.
On top of this skeleton, a LEFT JOIN with the real purchases would give the quarterly report by supplier with no gaps, including supplier 5 (EcoNordic Supplies, inactive), which would appear with all its quarters at NULL.
Conclusion
You've closed the two special JOINs:
- A
SELF JOINis an ordinaryJOINin which the same table appears twice. Aliases are mandatory —ERROR: table name specified more than once— and it's worth naming them after the role they play:e/manager,c/referrer,p1/p2. - The
employeeshierarchy is walked withe.manager_id = manager.id. With anINNER JOINyou lose the root (7 rows, no Rosa); with aLEFT JOINthe complete team appears (8 rows). In every hierarchy, the root has its FK atNULL. - The
customersreferral network works the same: 8 referred customers and 7 who arrived on their own. Lucía Martínez Soler brought in three. - For pairs within the same group —products from the same category, employees in the same city— the canonical pattern is
ON a.group = b.group AND a.id < b.id. The strict<removes reflexive pairs and inverted duplicates in one stroke: 29 pairs instead of 78. - A
SELF JOINwalks one level perJOIN. For arbitrary depth you need recursive CTEs (10-02). - The
CROSS JOINis the explicit cartesian product, with noON. It's an accident when the matching condition is forgotten, and a tool when it deliberately generates a report's skeleton (category × month) or a matrix of variants (size × colour). - PostgreSQL's
generate_seriesproduces numeric and date series usable in theFROM. Crossed with a dimension, it gives complete calendars with no gaps; in other engines you replace it with recursive CTEs or with a calendar table.
There's only one way of combining tables you still don't know. Every JOIN in this module adds columns: it takes a row from here, a row from there and glues them side by side. In the next lesson, UNION, INTERSECT and EXCEPT, you'll do the opposite: stack whole results vertically, adding rows instead of columns. You'll build a unified contact list out of customers, employees and suppliers; you'll find out which cities have both customers and employees; and you'll meet the three never-sold products again, this time without a single JOIN.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
